From e830ffe7a6e75544c414fb1978f7785499f82562 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 11 Jun 2026 10:18:05 +0200 Subject: [PATCH 1/2] Added checks to not flush records when a `diffTrigger` isn't setup yet. This fixes a non-critical error that was logged on startup. --- .changeset/five-turtles-yell.md | 5 +++++ .../powersync-db-collection/src/powersync.ts | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 .changeset/five-turtles-yell.md diff --git a/.changeset/five-turtles-yell.md b/.changeset/five-turtles-yell.md new file mode 100644 index 0000000000..cc8101a054 --- /dev/null +++ b/.changeset/five-turtles-yell.md @@ -0,0 +1,5 @@ +--- +'@tanstack/powersync-db-collection': patch +--- + +Added checks to not flush records when a `diffTrigger` isn't setup yet for `on-demand` mode. This fixes a non-critical error that was logged on startup. diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index c11c1d2698..b3564c4031 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -529,11 +529,14 @@ export function powerSyncCollectionOptions< onUnloadSubset = await restConfig.onLoadSubset?.(options) } + // Nothing to flush or dispose if no tracking table has been created yet. if (activeWhereExpressions.length === 0) { - await database.writeLock(async (ctx) => { - await flushDiffRecordsWithContext(ctx) - await disposeTracking?.({ context: ctx }) - }) + if (disposeTracking) { + await database.writeLock(async (ctx) => { + await flushDiffRecordsWithContext(ctx) + await disposeTracking?.({ context: ctx }) + }) + } return } @@ -563,8 +566,12 @@ export function powerSyncCollectionOptions< const viewWhereClause = toInlinedWhereClause(compiledView) await database.writeLock(async (ctx) => { - await flushDiffRecordsWithContext(ctx) - await disposeTracking?.({ context: ctx }) + // On the first loadSubset there is no tracking table yet, so there + // is nothing to flush or dispose. + if (disposeTracking) { + await flushDiffRecordsWithContext(ctx) + await disposeTracking({ context: ctx }) + } disposeTracking = await createDiffTrigger({ setupContext: ctx, From ebcae2e34040b41c8b34b06d2aad27334442fc98 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 27 Jul 2026 15:06:55 +0200 Subject: [PATCH 2/2] Clear tracking state on diff trigger disposal. --- .changeset/five-turtles-yell.md | 2 +- .../powersync-db-collection/src/powersync.ts | 48 +++-- .../tests/on-demand-sync.test.ts | 169 ++++++++++++++++++ 3 files changed, 203 insertions(+), 16 deletions(-) diff --git a/.changeset/five-turtles-yell.md b/.changeset/five-turtles-yell.md index cc8101a054..7e0a3df594 100644 --- a/.changeset/five-turtles-yell.md +++ b/.changeset/five-turtles-yell.md @@ -2,4 +2,4 @@ '@tanstack/powersync-db-collection': patch --- -Added checks to not flush records when a `diffTrigger` isn't setup yet for `on-demand` mode. This fixes a non-critical error that was logged on startup. +Fixed `no such table` errors logged by the `on-demand` sync handler. Records are no longer flushed before the `diffTrigger` has been set up, and the tracking state is now cleared as part of disposal so unloading a subset or cleaning up the collection no longer flushes the dropped tracking table. diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index b3564c4031..ed73b07016 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -317,6 +317,22 @@ export function powerSyncCollectionOptions< return runOnDemandSync() } + /** + * Disposes the current diff trigger, if one is active, and clears the + * tracking state. + */ + async function safelyDisposeTracking( + context?: LockContext, + ): Promise { + const dispose = disposeTracking + if (!dispose) { + return + } + + disposeTracking = null + await dispose(context ? { context } : undefined) + } + async function createDiffTrigger(options: { setupContext?: LockContext when: Record @@ -383,6 +399,11 @@ export function powerSyncCollectionOptions< async function flushDiffRecordsWithContext( context: LockContext, ): Promise { + // There is nothing to flush if no tracking table is currently active. + if (!disposeTracking) { + return + } + try { begin() const operations = await context.getAll( @@ -451,12 +472,12 @@ export function powerSyncCollectionOptions< // If the abort controller was aborted while processing the request above if (abortController.signal.aborted) { - await disposeTracking?.() + await safelyDisposeTracking() } else { abortController.signal.addEventListener( `abort`, async () => { - await disposeTracking?.() + await safelyDisposeTracking() }, { once: true }, ) @@ -529,14 +550,13 @@ export function powerSyncCollectionOptions< onUnloadSubset = await restConfig.onLoadSubset?.(options) } - // Nothing to flush or dispose if no tracking table has been created yet. + // No predicates remain, so stop tracking entirely. Both calls are no-ops + // when no tracking table is currently active. if (activeWhereExpressions.length === 0) { - if (disposeTracking) { - await database.writeLock(async (ctx) => { - await flushDiffRecordsWithContext(ctx) - await disposeTracking?.({ context: ctx }) - }) - } + await database.writeLock(async (ctx) => { + await flushDiffRecordsWithContext(ctx) + await safelyDisposeTracking(ctx) + }) return } @@ -566,12 +586,10 @@ export function powerSyncCollectionOptions< const viewWhereClause = toInlinedWhereClause(compiledView) await database.writeLock(async (ctx) => { - // On the first loadSubset there is no tracking table yet, so there - // is nothing to flush or dispose. - if (disposeTracking) { - await flushDiffRecordsWithContext(ctx) - await disposeTracking({ context: ctx }) - } + // Replace any active tracking with one covering the new set of + // predicates. + await flushDiffRecordsWithContext(ctx) + await safelyDisposeTracking(ctx) disposeTracking = await createDiffTrigger({ setupContext: ctx, diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index 4460fd0216..dffcc8505f 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -2112,4 +2112,173 @@ describe(`On-Demand Sync Mode`, () => { ) }) }) + + describe(`Tracking lifecycle`, () => { + // The sync handler catches its own errors and surfaces them only through the + // logger, so captured errors are how these tests assert it stayed healthy. + function captureSyncErrors(db: PowerSyncDatabase) { + const errors: Array = [] + vi.spyOn(db.logger, `error`).mockImplementation((...args: Array) => { + errors.push(args.map(String).join(` `)) + }) + return () => errors + } + + function makeCollection(db: PowerSyncDatabase) { + return createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.products, + syncMode: `on-demand`, + }), + ) + } + + function categoryQuery( + collection: ReturnType, + category: string, + ) { + return createLiveQueryCollection({ + query: (q) => + q + .from({ product: collection }) + .where(({ product }) => eq(product.category, category)) + .select(({ product }) => ({ + id: product.id, + name: product.name, + price: product.price, + category: product.category, + })), + }) + } + + it(`should start tracking again when a subset is loaded after every subset was unloaded`, async () => { + const db = await createDatabase() + await createTestProducts(db) + const syncErrors = captureSyncErrors(db) + + const collection = makeCollection(db) + onTestFinished(() => collection.cleanup()) + await collection.stateWhenReady() + + // Load a subset, then unload it so no predicates remain. Tracking stops and + // the tracking table is dropped. + const electronicsQuery = categoryQuery(collection, `electronics`) + await electronicsQuery.preload() + await vi.waitFor( + () => { + expect(electronicsQuery.size).toBe(3) + }, + { timeout: 2000 }, + ) + + electronicsQuery.cleanup() + await vi.waitFor( + () => { + expect(collection.size).toBe(0) + }, + { timeout: 2000 }, + ) + + // A new subset gets a freshly created tracking table and syncs normally. + const clothingQuery = categoryQuery(collection, `clothing`) + onTestFinished(() => clothingQuery.cleanup()) + await clothingQuery.preload() + + await vi.waitFor( + () => { + expect(clothingQuery.size).toBe(2) + }, + { timeout: 2000 }, + ) + + expect(syncErrors()).toEqual([]) + }) + + it(`should stop tracking cleanly when every subset is unloaded and the collection is cleaned up`, async () => { + const db = await createDatabase() + await createTestProducts(db) + const syncErrors = captureSyncErrors(db) + + const collection = makeCollection(db) + await collection.stateWhenReady() + + const electronicsQuery = categoryQuery(collection, `electronics`) + const clothingQuery = categoryQuery(collection, `clothing`) + await electronicsQuery.preload() + await clothingQuery.preload() + + await vi.waitFor( + () => { + expect(collection.size).toBe(5) + }, + { timeout: 2000 }, + ) + + // Unload every predicate, then tear the collection down. + clothingQuery.cleanup() + electronicsQuery.cleanup() + await vi.waitFor( + () => { + expect(collection.size).toBe(0) + }, + { timeout: 2000 }, + ) + + // Allow any flush queued by the tracking table's onChange watcher to run. + await new Promise((resolve) => setTimeout(resolve, 200)) + + collection.cleanup() + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(syncErrors()).toEqual([]) + }) + + it(`should dispose each diff trigger exactly once`, async () => { + const db = await createDatabase() + await createTestProducts(db) + + // Count dispose calls per created trigger. The collection should release its + // reference to a trigger once disposed, so no trigger is disposed twice. + const disposeCounts: Array = [] + const createDiffTrigger = db.triggers.createDiffTrigger.bind(db.triggers) + vi.spyOn(db.triggers, `createDiffTrigger`).mockImplementation( + async (options) => { + const dispose = await createDiffTrigger(options) + const index = disposeCounts.push(0) - 1 + return async (disposeOptions) => { + disposeCounts[index]! += 1 + return dispose(disposeOptions) + } + }, + ) + + const collection = makeCollection(db) + await collection.stateWhenReady() + + const electronicsQuery = categoryQuery(collection, `electronics`) + await electronicsQuery.preload() + await vi.waitFor( + () => { + expect(electronicsQuery.size).toBe(3) + }, + { timeout: 2000 }, + ) + + electronicsQuery.cleanup() + await vi.waitFor( + () => { + expect(collection.size).toBe(0) + }, + { timeout: 2000 }, + ) + + collection.cleanup() + await new Promise((resolve) => setTimeout(resolve, 200)) + + // One trigger is created for the electronics subset and disposed when that + // subset unloads. Cleaning up the collection must not dispose it again. + expect(disposeCounts).toEqual([1]) + }) + }) })