From 7d85d4835497ad6de1f72be986470c10524f391c Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 13 Apr 2026 13:45:05 +0200 Subject: [PATCH 1/2] Fixed bug where on-demand collections with the `id` column in their where clause would never be added to the PowerSync upload queue. --- .changeset/shy-nails-report.md | 5 + .../src/sqlite-compiler.ts | 6 +- .../tests/on-demand-sync.test.ts | 349 +++++++++++++++++- 3 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 .changeset/shy-nails-report.md diff --git a/.changeset/shy-nails-report.md b/.changeset/shy-nails-report.md new file mode 100644 index 000000000..3eaff2497 --- /dev/null +++ b/.changeset/shy-nails-report.md @@ -0,0 +1,5 @@ +--- +'@tanstack/powersync-db-collection': patch +--- + +Fixed bug where on-demand collections with the `id` column in their where clause would never be added to the PowerSync upload queue. diff --git a/packages/powersync-db-collection/src/sqlite-compiler.ts b/packages/powersync-db-collection/src/sqlite-compiler.ts index e2df875fc..ee8bb7200 100644 --- a/packages/powersync-db-collection/src/sqlite-compiler.ts +++ b/packages/powersync-db-collection/src/sqlite-compiler.ts @@ -95,7 +95,11 @@ function compileExpression( ) } const columnName = exp.path[0]! - if (compileOptions?.jsonColumn && columnName !== `id`) { + + if (compileOptions?.jsonColumn && columnName === `id`) { + const prefix = compileOptions.jsonColumn.split('.')[0]! + return `${prefix}.${quoteIdentifier(columnName)}` + } else if (compileOptions?.jsonColumn) { return `json_extract(${compileOptions.jsonColumn}, '$.${columnName}')` } return quoteIdentifier(columnName) 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 c084562ec..4460fd021 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -1012,7 +1012,9 @@ describe(`On-Demand Sync Mode`, () => { const productA = electronicsQuery.toArray.find( (p) => p.name === `Product A`, ) - await db.execute(`DELETE FROM products WHERE id = ?`, [productA!.id]) + + const tx = collection.delete(productA!.id) + await tx.isPersisted.promise await vi.waitFor( () => { @@ -1023,6 +1025,351 @@ describe(`On-Demand Sync Mode`, () => { const names = electronicsQuery.toArray.map((p) => p.name).sort() expect(names).toEqual([`Product B`, `Product D`]) + + // Verify the delete operation was recorded in the ps_crud table + const crud = await db.getAll<{ id: number; data: string; tx_id: number }>( + `SELECT * FROM ps_crud`, + ) + + const lastEntry = crud[crud.length - 1]! + const parsed = JSON.parse(lastEntry.data) + expect(parsed.op).toBe(`DELETE`) + expect(parsed.id).toBe(productA!.id) + }) + + it(`should handle INSERT of a matching row`, async () => { + const db = await createDatabase() + await createTestProducts(db) + + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.products, + syncMode: `on-demand`, + }), + ) + onTestFinished(() => collection.cleanup()) + await collection.stateWhenReady() + + const electronicsQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ product: collection }) + .where(({ product }) => eq(product.category, `electronics`)) + .select(({ product }) => ({ + id: product.id, + name: product.name, + price: product.price, + category: product.category, + })), + }) + onTestFinished(() => electronicsQuery.cleanup()) + + await electronicsQuery.preload() + + await vi.waitFor( + () => { + expect(electronicsQuery.size).toBe(3) + }, + { timeout: 2000 }, + ) + + // Insert a new electronics product via the collection + const newId = randomUUID() + const tx = collection.insert({ + id: newId, + name: `New Gadget`, + price: 99, + category: `electronics`, + }) + await tx.isPersisted.promise + + await vi.waitFor( + () => { + expect(electronicsQuery.size).toBe(4) + }, + { timeout: 2000 }, + ) + + const names = electronicsQuery.toArray.map((p) => p.name).sort() + expect(names).toContain(`New Gadget`) + + // Verify the insert operation was recorded in the ps_crud table + const crud = await db.getAll<{ id: number; data: string; tx_id: number }>( + `SELECT * FROM ps_crud`, + ) + + const lastEntry = crud[crud.length - 1]! + const parsed = JSON.parse(lastEntry.data) + expect(parsed.op).toBe(`PUT`) + expect(parsed.id).toBe(newId) + }) + + it(`should handle UPDATE of a matching row`, async () => { + const db = await createDatabase() + await createTestProducts(db) + + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.products, + syncMode: `on-demand`, + }), + ) + onTestFinished(() => collection.cleanup()) + await collection.stateWhenReady() + + const electronicsQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ product: collection }) + .where(({ product }) => eq(product.category, `electronics`)) + .select(({ product }) => ({ + id: product.id, + name: product.name, + price: product.price, + category: product.category, + })), + }) + onTestFinished(() => electronicsQuery.cleanup()) + + await electronicsQuery.preload() + + await vi.waitFor( + () => { + expect(electronicsQuery.size).toBe(3) + }, + { timeout: 2000 }, + ) + + // Update Product A via the collection + const productA = electronicsQuery.toArray.find( + (p) => p.name === `Product A`, + ) + + const tx = collection.update(productA!.id, (d) => { + d.price = 999 + }) + await tx.isPersisted.promise + + await vi.waitFor( + () => { + const product = electronicsQuery.toArray.find( + (p) => p.name === `Product A`, + ) + expect(product).toBeDefined() + expect(product!.price).toBe(999) + }, + { timeout: 2000 }, + ) + + // Verify the update operation was recorded in the ps_crud table + const crud = await db.getAll<{ id: number; data: string; tx_id: number }>( + `SELECT * FROM ps_crud`, + ) + + const lastEntry = crud[crud.length - 1]! + const parsed = JSON.parse(lastEntry.data) + expect(parsed.op).toBe(`PATCH`) + expect(parsed.id).toBe(productA!.id) + }) + + it(`should handle DELETE when read from collection by id`, async () => { + const db = await createDatabase() + await createTestProducts(db) + + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.products, + syncMode: `on-demand`, + }), + ) + onTestFinished(() => collection.cleanup()) + await collection.stateWhenReady() + + const productA = await db.get<{ id: string }>( + `SELECT id FROM products WHERE name = 'Product A'`, + ) + + const electronicsQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ product: collection }) + .where(({ product }) => eq(product.id, productA.id)) + .select(({ product }) => ({ + id: product.id, + name: product.name, + price: product.price, + category: product.category, + })), + }) + onTestFinished(() => electronicsQuery.cleanup()) + + await electronicsQuery.preload() + + await vi.waitFor( + () => { + expect(electronicsQuery.size).toBe(1) + }, + { timeout: 2000 }, + ) + + // Delete Product A + const tx = collection.delete(productA.id) + await tx.isPersisted.promise + + await vi.waitFor( + () => { + expect(electronicsQuery.size).toBe(0) + }, + { timeout: 2000 }, + ) + + const names = electronicsQuery.toArray.map((p) => p.name).sort() + expect(names).toEqual([]) + + // Verify the delete operation was recorded in the ps_crud table + const crud = await db.getAll<{ id: number; data: string; tx_id: number }>( + `SELECT * FROM ps_crud`, + ) + + const lastEntry = crud[crud.length - 1]! + const parsed = JSON.parse(lastEntry.data) + expect(parsed.op).toBe(`DELETE`) + expect(parsed.id).toBe(productA.id) + }) + + it(`should handle INSERT when loaded by id`, async () => { + const db = await createDatabase() + + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.products, + syncMode: `on-demand`, + }), + ) + onTestFinished(() => collection.cleanup()) + await collection.stateWhenReady() + + const newId = randomUUID() + + const idQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ product: collection }) + .where(({ product }) => eq(product.id, newId)) + .select(({ product }) => ({ + id: product.id, + name: product.name, + price: product.price, + category: product.category, + })), + }) + onTestFinished(() => idQuery.cleanup()) + + await idQuery.preload() + + await vi.waitFor( + () => { + expect(idQuery.size).toBe(0) + }, + { timeout: 2000 }, + ) + + // Insert a new product via the collection + const tx = collection.insert({ + id: newId, + name: `New Product`, + price: 99, + category: `electronics`, + }) + await tx.isPersisted.promise + + await vi.waitFor( + () => { + expect(idQuery.size).toBe(1) + }, + { timeout: 2000 }, + ) + + // Verify the insert operation was recorded in the ps_crud table + const crud = await db.getAll<{ id: number; data: string; tx_id: number }>( + `SELECT * FROM ps_crud`, + ) + + const lastEntry = crud[crud.length - 1]! + const parsed = JSON.parse(lastEntry.data) + expect(parsed.op).toBe(`PUT`) + expect(parsed.id).toBe(newId) + }) + + it(`should handle UPDATE when read from collection by id`, async () => { + const db = await createDatabase() + await createTestProducts(db) + + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.products, + syncMode: `on-demand`, + }), + ) + onTestFinished(() => collection.cleanup()) + await collection.stateWhenReady() + + const productA = await db.get<{ id: string }>( + `SELECT id FROM products WHERE name = 'Product A'`, + ) + + const idQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ product: collection }) + .where(({ product }) => eq(product.id, productA.id)) + .select(({ product }) => ({ + id: product.id, + name: product.name, + price: product.price, + category: product.category, + })), + }) + onTestFinished(() => idQuery.cleanup()) + + await idQuery.preload() + + await vi.waitFor( + () => { + expect(idQuery.size).toBe(1) + }, + { timeout: 2000 }, + ) + + // Update Product A via the collection + const tx = collection.update(productA.id, (d) => { + d.price = 999 + }) + await tx.isPersisted.promise + + await vi.waitFor( + () => { + const product = idQuery.toArray[0] + expect(product).toBeDefined() + expect(product!.price).toBe(999) + }, + { timeout: 2000 }, + ) + + // Verify the update operation was recorded in the ps_crud table + const crud = await db.getAll<{ id: number; data: string; tx_id: number }>( + `SELECT * FROM ps_crud`, + ) + + const lastEntry = crud[crud.length - 1]! + const parsed = JSON.parse(lastEntry.data) + expect(parsed.op).toBe(`PATCH`) + expect(parsed.id).toBe(productA.id) }) }) From 53bd45fa3f125843d24aff10c940e3aebacb22d6 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 13 Apr 2026 14:41:53 +0200 Subject: [PATCH 2/2] Added minor comment. --- packages/powersync-db-collection/src/sqlite-compiler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/powersync-db-collection/src/sqlite-compiler.ts b/packages/powersync-db-collection/src/sqlite-compiler.ts index ee8bb7200..d5d027496 100644 --- a/packages/powersync-db-collection/src/sqlite-compiler.ts +++ b/packages/powersync-db-collection/src/sqlite-compiler.ts @@ -96,6 +96,10 @@ function compileExpression( } const columnName = exp.path[0]! + // PowerSync stores `id` as a top-level row column rather than inside the + // JSON `data` object, so we skip json_extract. However, when compiling for + // trigger WHEN clauses we still need the OLD./NEW. prefix. Extract it from + // the jsonColumn option. if (compileOptions?.jsonColumn && columnName === `id`) { const prefix = compileOptions.jsonColumn.split('.')[0]! return `${prefix}.${quoteIdentifier(columnName)}`