Skip to content

Commit 06849ef

Browse files
authored
fix(powersync-db-collection): "id" in trigger WHEN clauses breaks DELETE for on-demand PowerSync collections (#1470)
* Fixed bug where on-demand collections with the `id` column in their where clause would never be added to the PowerSync upload queue. * Added minor comment.
1 parent c28ab1e commit 06849ef

3 files changed

Lines changed: 362 additions & 2 deletions

File tree

.changeset/shy-nails-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/powersync-db-collection': patch
3+
---
4+
5+
Fixed bug where on-demand collections with the `id` column in their where clause would never be added to the PowerSync upload queue.

packages/powersync-db-collection/src/sqlite-compiler.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,15 @@ function compileExpression(
9595
)
9696
}
9797
const columnName = exp.path[0]!
98-
if (compileOptions?.jsonColumn && columnName !== `id`) {
98+
99+
// PowerSync stores `id` as a top-level row column rather than inside the
100+
// JSON `data` object, so we skip json_extract. However, when compiling for
101+
// trigger WHEN clauses we still need the OLD./NEW. prefix. Extract it from
102+
// the jsonColumn option.
103+
if (compileOptions?.jsonColumn && columnName === `id`) {
104+
const prefix = compileOptions.jsonColumn.split('.')[0]!
105+
return `${prefix}.${quoteIdentifier(columnName)}`
106+
} else if (compileOptions?.jsonColumn) {
99107
return `json_extract(${compileOptions.jsonColumn}, '$.${columnName}')`
100108
}
101109
return quoteIdentifier(columnName)

packages/powersync-db-collection/tests/on-demand-sync.test.ts

Lines changed: 348 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,9 @@ describe(`On-Demand Sync Mode`, () => {
10121012
const productA = electronicsQuery.toArray.find(
10131013
(p) => p.name === `Product A`,
10141014
)
1015-
await db.execute(`DELETE FROM products WHERE id = ?`, [productA!.id])
1015+
1016+
const tx = collection.delete(productA!.id)
1017+
await tx.isPersisted.promise
10161018

10171019
await vi.waitFor(
10181020
() => {
@@ -1023,6 +1025,351 @@ describe(`On-Demand Sync Mode`, () => {
10231025

10241026
const names = electronicsQuery.toArray.map((p) => p.name).sort()
10251027
expect(names).toEqual([`Product B`, `Product D`])
1028+
1029+
// Verify the delete operation was recorded in the ps_crud table
1030+
const crud = await db.getAll<{ id: number; data: string; tx_id: number }>(
1031+
`SELECT * FROM ps_crud`,
1032+
)
1033+
1034+
const lastEntry = crud[crud.length - 1]!
1035+
const parsed = JSON.parse(lastEntry.data)
1036+
expect(parsed.op).toBe(`DELETE`)
1037+
expect(parsed.id).toBe(productA!.id)
1038+
})
1039+
1040+
it(`should handle INSERT of a matching row`, async () => {
1041+
const db = await createDatabase()
1042+
await createTestProducts(db)
1043+
1044+
const collection = createCollection(
1045+
powerSyncCollectionOptions({
1046+
database: db,
1047+
table: APP_SCHEMA.props.products,
1048+
syncMode: `on-demand`,
1049+
}),
1050+
)
1051+
onTestFinished(() => collection.cleanup())
1052+
await collection.stateWhenReady()
1053+
1054+
const electronicsQuery = createLiveQueryCollection({
1055+
query: (q) =>
1056+
q
1057+
.from({ product: collection })
1058+
.where(({ product }) => eq(product.category, `electronics`))
1059+
.select(({ product }) => ({
1060+
id: product.id,
1061+
name: product.name,
1062+
price: product.price,
1063+
category: product.category,
1064+
})),
1065+
})
1066+
onTestFinished(() => electronicsQuery.cleanup())
1067+
1068+
await electronicsQuery.preload()
1069+
1070+
await vi.waitFor(
1071+
() => {
1072+
expect(electronicsQuery.size).toBe(3)
1073+
},
1074+
{ timeout: 2000 },
1075+
)
1076+
1077+
// Insert a new electronics product via the collection
1078+
const newId = randomUUID()
1079+
const tx = collection.insert({
1080+
id: newId,
1081+
name: `New Gadget`,
1082+
price: 99,
1083+
category: `electronics`,
1084+
})
1085+
await tx.isPersisted.promise
1086+
1087+
await vi.waitFor(
1088+
() => {
1089+
expect(electronicsQuery.size).toBe(4)
1090+
},
1091+
{ timeout: 2000 },
1092+
)
1093+
1094+
const names = electronicsQuery.toArray.map((p) => p.name).sort()
1095+
expect(names).toContain(`New Gadget`)
1096+
1097+
// Verify the insert operation was recorded in the ps_crud table
1098+
const crud = await db.getAll<{ id: number; data: string; tx_id: number }>(
1099+
`SELECT * FROM ps_crud`,
1100+
)
1101+
1102+
const lastEntry = crud[crud.length - 1]!
1103+
const parsed = JSON.parse(lastEntry.data)
1104+
expect(parsed.op).toBe(`PUT`)
1105+
expect(parsed.id).toBe(newId)
1106+
})
1107+
1108+
it(`should handle UPDATE of a matching row`, async () => {
1109+
const db = await createDatabase()
1110+
await createTestProducts(db)
1111+
1112+
const collection = createCollection(
1113+
powerSyncCollectionOptions({
1114+
database: db,
1115+
table: APP_SCHEMA.props.products,
1116+
syncMode: `on-demand`,
1117+
}),
1118+
)
1119+
onTestFinished(() => collection.cleanup())
1120+
await collection.stateWhenReady()
1121+
1122+
const electronicsQuery = createLiveQueryCollection({
1123+
query: (q) =>
1124+
q
1125+
.from({ product: collection })
1126+
.where(({ product }) => eq(product.category, `electronics`))
1127+
.select(({ product }) => ({
1128+
id: product.id,
1129+
name: product.name,
1130+
price: product.price,
1131+
category: product.category,
1132+
})),
1133+
})
1134+
onTestFinished(() => electronicsQuery.cleanup())
1135+
1136+
await electronicsQuery.preload()
1137+
1138+
await vi.waitFor(
1139+
() => {
1140+
expect(electronicsQuery.size).toBe(3)
1141+
},
1142+
{ timeout: 2000 },
1143+
)
1144+
1145+
// Update Product A via the collection
1146+
const productA = electronicsQuery.toArray.find(
1147+
(p) => p.name === `Product A`,
1148+
)
1149+
1150+
const tx = collection.update(productA!.id, (d) => {
1151+
d.price = 999
1152+
})
1153+
await tx.isPersisted.promise
1154+
1155+
await vi.waitFor(
1156+
() => {
1157+
const product = electronicsQuery.toArray.find(
1158+
(p) => p.name === `Product A`,
1159+
)
1160+
expect(product).toBeDefined()
1161+
expect(product!.price).toBe(999)
1162+
},
1163+
{ timeout: 2000 },
1164+
)
1165+
1166+
// Verify the update operation was recorded in the ps_crud table
1167+
const crud = await db.getAll<{ id: number; data: string; tx_id: number }>(
1168+
`SELECT * FROM ps_crud`,
1169+
)
1170+
1171+
const lastEntry = crud[crud.length - 1]!
1172+
const parsed = JSON.parse(lastEntry.data)
1173+
expect(parsed.op).toBe(`PATCH`)
1174+
expect(parsed.id).toBe(productA!.id)
1175+
})
1176+
1177+
it(`should handle DELETE when read from collection by id`, async () => {
1178+
const db = await createDatabase()
1179+
await createTestProducts(db)
1180+
1181+
const collection = createCollection(
1182+
powerSyncCollectionOptions({
1183+
database: db,
1184+
table: APP_SCHEMA.props.products,
1185+
syncMode: `on-demand`,
1186+
}),
1187+
)
1188+
onTestFinished(() => collection.cleanup())
1189+
await collection.stateWhenReady()
1190+
1191+
const productA = await db.get<{ id: string }>(
1192+
`SELECT id FROM products WHERE name = 'Product A'`,
1193+
)
1194+
1195+
const electronicsQuery = createLiveQueryCollection({
1196+
query: (q) =>
1197+
q
1198+
.from({ product: collection })
1199+
.where(({ product }) => eq(product.id, productA.id))
1200+
.select(({ product }) => ({
1201+
id: product.id,
1202+
name: product.name,
1203+
price: product.price,
1204+
category: product.category,
1205+
})),
1206+
})
1207+
onTestFinished(() => electronicsQuery.cleanup())
1208+
1209+
await electronicsQuery.preload()
1210+
1211+
await vi.waitFor(
1212+
() => {
1213+
expect(electronicsQuery.size).toBe(1)
1214+
},
1215+
{ timeout: 2000 },
1216+
)
1217+
1218+
// Delete Product A
1219+
const tx = collection.delete(productA.id)
1220+
await tx.isPersisted.promise
1221+
1222+
await vi.waitFor(
1223+
() => {
1224+
expect(electronicsQuery.size).toBe(0)
1225+
},
1226+
{ timeout: 2000 },
1227+
)
1228+
1229+
const names = electronicsQuery.toArray.map((p) => p.name).sort()
1230+
expect(names).toEqual([])
1231+
1232+
// Verify the delete operation was recorded in the ps_crud table
1233+
const crud = await db.getAll<{ id: number; data: string; tx_id: number }>(
1234+
`SELECT * FROM ps_crud`,
1235+
)
1236+
1237+
const lastEntry = crud[crud.length - 1]!
1238+
const parsed = JSON.parse(lastEntry.data)
1239+
expect(parsed.op).toBe(`DELETE`)
1240+
expect(parsed.id).toBe(productA.id)
1241+
})
1242+
1243+
it(`should handle INSERT when loaded by id`, async () => {
1244+
const db = await createDatabase()
1245+
1246+
const collection = createCollection(
1247+
powerSyncCollectionOptions({
1248+
database: db,
1249+
table: APP_SCHEMA.props.products,
1250+
syncMode: `on-demand`,
1251+
}),
1252+
)
1253+
onTestFinished(() => collection.cleanup())
1254+
await collection.stateWhenReady()
1255+
1256+
const newId = randomUUID()
1257+
1258+
const idQuery = createLiveQueryCollection({
1259+
query: (q) =>
1260+
q
1261+
.from({ product: collection })
1262+
.where(({ product }) => eq(product.id, newId))
1263+
.select(({ product }) => ({
1264+
id: product.id,
1265+
name: product.name,
1266+
price: product.price,
1267+
category: product.category,
1268+
})),
1269+
})
1270+
onTestFinished(() => idQuery.cleanup())
1271+
1272+
await idQuery.preload()
1273+
1274+
await vi.waitFor(
1275+
() => {
1276+
expect(idQuery.size).toBe(0)
1277+
},
1278+
{ timeout: 2000 },
1279+
)
1280+
1281+
// Insert a new product via the collection
1282+
const tx = collection.insert({
1283+
id: newId,
1284+
name: `New Product`,
1285+
price: 99,
1286+
category: `electronics`,
1287+
})
1288+
await tx.isPersisted.promise
1289+
1290+
await vi.waitFor(
1291+
() => {
1292+
expect(idQuery.size).toBe(1)
1293+
},
1294+
{ timeout: 2000 },
1295+
)
1296+
1297+
// Verify the insert operation was recorded in the ps_crud table
1298+
const crud = await db.getAll<{ id: number; data: string; tx_id: number }>(
1299+
`SELECT * FROM ps_crud`,
1300+
)
1301+
1302+
const lastEntry = crud[crud.length - 1]!
1303+
const parsed = JSON.parse(lastEntry.data)
1304+
expect(parsed.op).toBe(`PUT`)
1305+
expect(parsed.id).toBe(newId)
1306+
})
1307+
1308+
it(`should handle UPDATE when read from collection by id`, async () => {
1309+
const db = await createDatabase()
1310+
await createTestProducts(db)
1311+
1312+
const collection = createCollection(
1313+
powerSyncCollectionOptions({
1314+
database: db,
1315+
table: APP_SCHEMA.props.products,
1316+
syncMode: `on-demand`,
1317+
}),
1318+
)
1319+
onTestFinished(() => collection.cleanup())
1320+
await collection.stateWhenReady()
1321+
1322+
const productA = await db.get<{ id: string }>(
1323+
`SELECT id FROM products WHERE name = 'Product A'`,
1324+
)
1325+
1326+
const idQuery = createLiveQueryCollection({
1327+
query: (q) =>
1328+
q
1329+
.from({ product: collection })
1330+
.where(({ product }) => eq(product.id, productA.id))
1331+
.select(({ product }) => ({
1332+
id: product.id,
1333+
name: product.name,
1334+
price: product.price,
1335+
category: product.category,
1336+
})),
1337+
})
1338+
onTestFinished(() => idQuery.cleanup())
1339+
1340+
await idQuery.preload()
1341+
1342+
await vi.waitFor(
1343+
() => {
1344+
expect(idQuery.size).toBe(1)
1345+
},
1346+
{ timeout: 2000 },
1347+
)
1348+
1349+
// Update Product A via the collection
1350+
const tx = collection.update(productA.id, (d) => {
1351+
d.price = 999
1352+
})
1353+
await tx.isPersisted.promise
1354+
1355+
await vi.waitFor(
1356+
() => {
1357+
const product = idQuery.toArray[0]
1358+
expect(product).toBeDefined()
1359+
expect(product!.price).toBe(999)
1360+
},
1361+
{ timeout: 2000 },
1362+
)
1363+
1364+
// Verify the update operation was recorded in the ps_crud table
1365+
const crud = await db.getAll<{ id: number; data: string; tx_id: number }>(
1366+
`SELECT * FROM ps_crud`,
1367+
)
1368+
1369+
const lastEntry = crud[crud.length - 1]!
1370+
const parsed = JSON.parse(lastEntry.data)
1371+
expect(parsed.op).toBe(`PATCH`)
1372+
expect(parsed.id).toBe(productA.id)
10261373
})
10271374
})
10281375

0 commit comments

Comments
 (0)