fix(mana/web/sync): push fresh writes immediately via listener bridge

createUnifiedSync exported onPendingChange but nothing ever called it,
so the Dexie hook in database.ts recorded _pendingChanges rows that
the sync engine never heard about. Live writes only ever drained on
the next page reload (via drainLeftoverPending). Observed live as
fresh calendar/timeblocks writes piling up in _pendingChanges with
zero POST traffic to sync.mana.how.

Add a listener bridge: database.ts exposes setPendingChangeListener,
trackPendingChange invokes it after each successful _pendingChanges
insert, and sync.ts registers schedulePush (gated on a known channel)
inside startAll. stopAll clears the listener so a torn-down sync
engine can't get re-triggered by a stale callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 14:13:25 +02:00
parent 6cbb1f64d0
commit 7fa3afcdc7
2 changed files with 41 additions and 11 deletions

View file

@ -346,20 +346,39 @@ const pendingChangesTable = db.table('_pendingChanges');
* `_pendingChanges` from there hits `NotFoundError: object store not in
* scope`. We defer the write to a microtask so it runs in a fresh
* transaction after the user's commit lands.
*
* After the row lands, `pendingChangeListener` (set by sync.ts at startup)
* is invoked with the change's appId so the unified sync engine can push
* immediately. Without that listener, pending rows would only ever drain
* on the next page reload via drainLeftoverPending.
*/
let pendingChangeListener: ((appId: string) => void) | null = null;
export function setPendingChangeListener(fn: ((appId: string) => void) | null): void {
pendingChangeListener = fn;
}
function trackPendingChange(table: string, change: Record<string, unknown>): void {
// setTimeout (not queueMicrotask) is required: Dexie binds the active
// transaction to the current Zone via Promise scheduling, and a microtask
// is still considered "inside" the transaction. setTimeout(0) breaks out
// completely so the new add() spawns its own implicit transaction.
setTimeout(() => {
pendingChangesTable.add(change).catch((err: unknown) => {
if (isQuotaError(err)) {
notifyQuotaExceeded({ table, op: 'pending-change', cleaned: 0, recovered: false });
} else {
console.error('[mana-sync] failed to record pending change:', err);
}
});
pendingChangesTable
.add(change)
.then(() => {
const appId = change.appId;
if (typeof appId === 'string' && pendingChangeListener) {
pendingChangeListener(appId);
}
})
.catch((err: unknown) => {
if (isQuotaError(err)) {
notifyQuotaExceeded({ table, op: 'pending-change', cleaned: 0, recovered: false });
} else {
console.error('[mana-sync] failed to record pending change:', err);
}
});
}, 0);
}

View file

@ -21,6 +21,7 @@ import {
fromSyncName,
beginApplyingTables,
FIELD_TIMESTAMPS_KEY,
setPendingChangeListener,
} from './database';
import { isQuotaError, cleanupTombstones, notifyQuotaExceeded } from './quota';
import { emitSyncTelemetry, categorizeSyncError } from './sync-telemetry';
@ -551,11 +552,20 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
// Lazy apps: no pull until ensureAppSynced() is called
}
// Wire fresh pending writes back to schedulePush. Without this, the
// Dexie hook in database.ts records the row but the sync engine
// never hears about it — pending accumulates indefinitely until the
// next reload triggers drainLeftoverPending below. Register before
// the drain so any change that lands during the drain itself is
// also caught.
setPendingChangeListener((appId) => {
if (channels.has(appId)) schedulePush(appId);
});
// Drain leftover pending changes from previous sessions. Without this,
// pending writes survive across reloads but never push because
// schedulePush() only fires on fresh Dexie writes — channels start
// cold and the queue stays stuck until the user happens to mutate
// the same table again.
// pending writes that survived a reload would sit until the user
// happens to mutate the same table again — even with the listener
// above wired up, leftovers never trigger it.
drainLeftoverPending();
// Listen for online/offline
@ -585,6 +595,7 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
}
function stopAll(): void {
setPendingChangeListener(null);
for (const [, channel] of channels) {
if (channel.pushTimer) clearTimeout(channel.pushTimer);
if (channel.pullTimer) clearInterval(channel.pullTimer);