From 7fa3afcdc7e090aea49acedcc26ec9956622f677 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 14:13:25 +0200 Subject: [PATCH] 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) --- apps/mana/apps/web/src/lib/data/database.ts | 33 ++++++++++++++++----- apps/mana/apps/web/src/lib/data/sync.ts | 19 +++++++++--- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index ea860ab3a..c8cb50d0b 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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): 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); } diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index 1cf435317..b6509c990 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -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 { + 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