Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shy-nails-report.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion packages/powersync-db-collection/src/sqlite-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,15 @@ function compileExpression(
)
}
const columnName = exp.path[0]!
if (compileOptions?.jsonColumn && columnName !== `id`) {

// 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)}`
} else if (compileOptions?.jsonColumn) {
return `json_extract(${compileOptions.jsonColumn}, '$.${columnName}')`
}
return quoteIdentifier(columnName)
Expand Down
349 changes: 348 additions & 1 deletion packages/powersync-db-collection/tests/on-demand-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() => {
Expand All @@ -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)
})
})

Expand Down
Loading