@syncular/server 0.42.2 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/d1-storage.js +6 -3
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +4 -4
- package/dist/postgres-storage.js +17 -1
- package/dist/push.d.ts +15 -1
- package/dist/push.js +200 -60
- package/dist/sqlite-storage.js +6 -3
- package/dist/storage.d.ts +7 -0
- package/dist/storage.js +26 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +12 -3
- package/src/operations.ts +12 -7
- package/src/postgres-storage.ts +22 -0
- package/src/push.ts +295 -63
- package/src/sqlite-storage.ts +8 -2
- package/src/storage.ts +27 -0
package/dist/d1-storage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
var _a;
|
|
2
2
|
import { validateCommitPruneQuery } from './prune.js';
|
|
3
3
|
import { StorageQueryError } from './storage-errors.js';
|
|
4
|
-
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
|
|
4
|
+
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, rowsLiveAfterDelete, } from './storage.js';
|
|
5
5
|
/**
|
|
6
6
|
* Cloudflare D1 server storage for Workers deployments.
|
|
7
7
|
*
|
|
@@ -480,6 +480,7 @@ class D1Transaction {
|
|
|
480
480
|
commit.actorId,
|
|
481
481
|
commit.createdAtMs,
|
|
482
482
|
]);
|
|
483
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
483
484
|
commit.changes.forEach((change, idx) => {
|
|
484
485
|
this.#buffer_('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)', [
|
|
485
486
|
p,
|
|
@@ -492,9 +493,11 @@ class D1Transaction {
|
|
|
492
493
|
JSON.stringify(change.scopes),
|
|
493
494
|
change.payload ?? null,
|
|
494
495
|
]);
|
|
495
|
-
if (change.op === 'delete'
|
|
496
|
+
if (change.op === 'delete' &&
|
|
497
|
+
!liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)) {
|
|
496
498
|
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
497
|
-
// pruned with the commit log (§4.6).
|
|
499
|
+
// pruned with the commit log (§4.6). A delete that the same commit
|
|
500
|
+
// follows with an upsert is a §2.2 scope move, not a deletion.
|
|
498
501
|
this.#buffer_(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
499
502
|
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`, [p, change.table, change.rowId, commitSeq]);
|
|
500
503
|
}
|
package/dist/operations.d.ts
CHANGED
|
@@ -51,6 +51,11 @@ export type CommandMutation = {
|
|
|
51
51
|
readonly table: string;
|
|
52
52
|
readonly op: 'upsert';
|
|
53
53
|
readonly values: Readonly<Record<string, RowValue>>;
|
|
54
|
+
} | {
|
|
55
|
+
readonly table: string;
|
|
56
|
+
readonly op: 'rescope';
|
|
57
|
+
/** Full row; only its scope columns may differ from the stored row. */
|
|
58
|
+
readonly values: Readonly<Record<string, RowValue>>;
|
|
54
59
|
} | {
|
|
55
60
|
readonly table: string;
|
|
56
61
|
readonly op: 'delete';
|
package/dist/operations.js
CHANGED
|
@@ -256,23 +256,23 @@ function commandOperations(mutations, schema) {
|
|
|
256
256
|
const supplied = new Set(Object.keys(mutation.values));
|
|
257
257
|
for (const name of supplied) {
|
|
258
258
|
if (!table.columnIndex.has(name)) {
|
|
259
|
-
throw syncError('operation.invalid_request', 'command upsert has an unknown column');
|
|
259
|
+
throw syncError('operation.invalid_request', 'command upsert or rescope has an unknown column');
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
262
|
const values = table.columns.map((column) => {
|
|
263
263
|
if (!supplied.has(column.name)) {
|
|
264
|
-
throw syncError('operation.invalid_request', 'command upsert must provide a full row');
|
|
264
|
+
throw syncError('operation.invalid_request', 'command upsert or rescope must provide a full row');
|
|
265
265
|
}
|
|
266
266
|
return mutation.values[column.name] ?? null;
|
|
267
267
|
});
|
|
268
268
|
const rowId = renderScopeValue(values[table.primaryKeyIndex]);
|
|
269
269
|
if (rowId === undefined) {
|
|
270
|
-
throw syncError('operation.invalid_request', 'command upsert requires a non-empty primary key');
|
|
270
|
+
throw syncError('operation.invalid_request', 'command upsert or rescope requires a non-empty primary key');
|
|
271
271
|
}
|
|
272
272
|
return {
|
|
273
273
|
table: table.name,
|
|
274
274
|
rowId,
|
|
275
|
-
op:
|
|
275
|
+
op: mutation.op,
|
|
276
276
|
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
277
277
|
};
|
|
278
278
|
});
|
package/dist/postgres-storage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune.js';
|
|
2
2
|
import { StorageQueryError } from './storage-errors.js';
|
|
3
|
-
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal, } from './storage.js';
|
|
3
|
+
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, rowsLiveAfterDelete, serveGateRefusal, } from './storage.js';
|
|
4
4
|
import { serveNotReadyError } from './readiness.js';
|
|
5
5
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
6
6
|
import { syncError } from './errors.js';
|
|
@@ -895,6 +895,22 @@ class PostgresTransaction {
|
|
|
895
895
|
ON CONFLICT (partition, tbl, row_id)
|
|
896
896
|
DO UPDATE SET commit_seq = excluded.commit_seq`, params);
|
|
897
897
|
}
|
|
898
|
+
// §2.2: a delete whose commit follows it with an upsert is a scope move.
|
|
899
|
+
// The row is live, so no tombstone may remain for it.
|
|
900
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
901
|
+
if (liveAfterDelete.size > 0) {
|
|
902
|
+
await q.query(`DELETE FROM sync_tombstones existing
|
|
903
|
+
USING jsonb_to_recordset($2::text::jsonb) AS moved(tbl text, row_id text)
|
|
904
|
+
WHERE existing.partition=$1
|
|
905
|
+
AND existing.tbl=moved.tbl
|
|
906
|
+
AND existing.row_id=moved.row_id`, [
|
|
907
|
+
p,
|
|
908
|
+
JSON.stringify([...liveAfterDelete].map((key) => {
|
|
909
|
+
const at = key.indexOf('\u0000');
|
|
910
|
+
return { tbl: key.slice(0, at), row_id: key.slice(at + 1) };
|
|
911
|
+
})),
|
|
912
|
+
]);
|
|
913
|
+
}
|
|
898
914
|
return commitSeq;
|
|
899
915
|
}
|
|
900
916
|
async putPushResult(clientId, clientCommitId, result) {
|
package/dist/push.d.ts
CHANGED
|
@@ -28,6 +28,20 @@ import type { StorageTransaction, StoredCommit } from './storage.js';
|
|
|
28
28
|
* row-codec decode (§5.9.1), so `parseBlobRef` here is total.
|
|
29
29
|
*/
|
|
30
30
|
export declare function blobIdsInRow(table: CompiledTable, values: readonly RowValue[]): string[];
|
|
31
|
+
/**
|
|
32
|
+
* A server-authoritative scope move (§2.2). Commands emit it through
|
|
33
|
+
* `CommandMutation`; it is not an SSP2 wire operation, so a client cannot
|
|
34
|
+
* request one. Apply rewrites the stored row in place under the new scopes
|
|
35
|
+
* and emits the delete/upsert change pair in one commit.
|
|
36
|
+
*/
|
|
37
|
+
export interface RescopeOperation {
|
|
38
|
+
readonly table: string;
|
|
39
|
+
readonly rowId: string;
|
|
40
|
+
readonly op: 'rescope';
|
|
41
|
+
/** Full-row payload (the row's values under the new scopes). */
|
|
42
|
+
readonly payload: Uint8Array;
|
|
43
|
+
}
|
|
44
|
+
export type ApplyOperation = PushOperation | RescopeOperation;
|
|
31
45
|
export interface ProcessedPushCommit {
|
|
32
46
|
readonly frame: PushResultFrame;
|
|
33
47
|
/** True when this request observed an already-recorded idempotency outcome. */
|
|
@@ -54,4 +68,4 @@ export declare function processPushCommitWithTrace(ctx: SyncRequestContext, sche
|
|
|
54
68
|
* The builder runs after the partition lock and idempotency re-check, so its
|
|
55
69
|
* reads and the returned operations share the transaction that is committed.
|
|
56
70
|
*/
|
|
57
|
-
export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly
|
|
71
|
+
export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly ApplyOperation[]>, wireSchema?: CompiledSchema): Promise<ProcessedPushCommit>;
|
package/dist/push.js
CHANGED
|
@@ -172,20 +172,149 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
172
172
|
return errorRecord(opIndex, 'sync.forbidden', 'scope resolution failed (§3.4 step 4)');
|
|
173
173
|
}
|
|
174
174
|
const stored = await tx.getRow(op.table, op.rowId);
|
|
175
|
-
if (op.op === '
|
|
175
|
+
if (op.op === 'rescope') {
|
|
176
|
+
// §2.2 explicit scope move. One stored row changes its scope values;
|
|
177
|
+
// apply rewrites it in place, keeps `server_version`, and emits the
|
|
178
|
+
// delete under the old scopes plus the upsert under the new ones.
|
|
176
179
|
if (stored === undefined) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
return errorRecord(opIndex, 'sync.row_missing', 'rescope targets an absent row (§2.2)');
|
|
181
|
+
}
|
|
182
|
+
if (!authorizeWrite(table, stored.scopes, resolved)) {
|
|
183
|
+
return errorRecord(opIndex, 'sync.forbidden', 'rescope denied by scope authorization (§3.4)');
|
|
184
|
+
}
|
|
185
|
+
const missing = missingScopeVariable(table, stored.scopes);
|
|
186
|
+
if (missing !== undefined) {
|
|
187
|
+
return errorRecord(opIndex, 'sync.missing_scopes', `stored row lacks scope variable ${JSON.stringify(missing)} (§3.1)`);
|
|
188
|
+
}
|
|
189
|
+
let sparse;
|
|
190
|
+
try {
|
|
191
|
+
sparse = decodeSparseRow(wireTable.columns, wireTable.primaryKeyIndex, op.payload);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
return errorRecord(opIndex, 'sync.invalid_request', `rescope payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
196
|
+
if (sparse.length < table.columns.length ||
|
|
197
|
+
sparse.some((value) => value === undefined)) {
|
|
198
|
+
return errorRecord(opIndex, 'sync.invalid_request', 'rescope requires a full-row payload (§2.2)');
|
|
199
|
+
}
|
|
200
|
+
const values = sparse.map((value) => value ?? null);
|
|
201
|
+
const pkValue = renderScopeValue(values[table.primaryKeyIndex] ?? null);
|
|
202
|
+
if (pkValue !== op.rowId) {
|
|
203
|
+
return errorRecord(opIndex, 'sync.invalid_request', 'payload primary key does not match rowId');
|
|
204
|
+
}
|
|
205
|
+
const storedValues = decodeRow(table.columns, stored.payload);
|
|
206
|
+
const scopeIndexes = new Set(table.scopePatterns.map((pattern) => pattern.columnIndex));
|
|
207
|
+
const sameValue = (left, right) => {
|
|
208
|
+
if (left instanceof Uint8Array || right instanceof Uint8Array) {
|
|
209
|
+
return (left instanceof Uint8Array &&
|
|
210
|
+
right instanceof Uint8Array &&
|
|
211
|
+
left.length === right.length &&
|
|
212
|
+
left.every((byte, index) => byte === right[index]));
|
|
213
|
+
}
|
|
214
|
+
return left === right;
|
|
215
|
+
};
|
|
216
|
+
for (let index = 0; index < table.columns.length; index++) {
|
|
217
|
+
if (index === table.primaryKeyIndex || scopeIndexes.has(index))
|
|
218
|
+
continue;
|
|
219
|
+
if (!sameValue(values[index] ?? null, storedValues[index] ?? null)) {
|
|
220
|
+
return errorRecord(opIndex, 'sync.invalid_request', 'rescope may change only scope columns (§2.2)');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const extracted = storedScopesForRow(table, values);
|
|
224
|
+
if ('missing' in extracted) {
|
|
225
|
+
return errorRecord(opIndex, 'sync.forbidden', `rescope missing scope column value for ${JSON.stringify(extracted.missing)} (§3.4)`);
|
|
226
|
+
}
|
|
227
|
+
if (!authorizeWrite(table, extracted.scopes, resolved)) {
|
|
228
|
+
return errorRecord(opIndex, 'sync.forbidden', 'rescope denied by scope authorization of the new scopes (§3.4)');
|
|
229
|
+
}
|
|
230
|
+
const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, tx, partition, actorId);
|
|
231
|
+
if (deleteReject !== undefined)
|
|
232
|
+
return deleteReject;
|
|
233
|
+
const upsertReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, tx, partition, actorId);
|
|
234
|
+
if (upsertReject !== undefined)
|
|
235
|
+
return upsertReject;
|
|
236
|
+
const blobCheck = await checkAndRecordBlobs(tx, table, op.rowId, values, opIndex, blobCtx);
|
|
237
|
+
if (blobCheck !== undefined)
|
|
238
|
+
return blobCheck;
|
|
239
|
+
const payload = encodeRow(table.columns, values);
|
|
240
|
+
// The move clears any tombstone from an earlier incarnation: the row is
|
|
241
|
+
// live at the end of this commit, so the next move must not hit §5.2.
|
|
242
|
+
await tx.clearTombstone(op.table, op.rowId);
|
|
243
|
+
await tx.upsertRow(op.table, {
|
|
244
|
+
rowId: op.rowId,
|
|
245
|
+
serverVersion: stored.serverVersion,
|
|
246
|
+
scopes: extracted.scopes,
|
|
247
|
+
payload,
|
|
248
|
+
...(stored.columnVersions !== undefined
|
|
249
|
+
? { columnVersions: stored.columnVersions }
|
|
250
|
+
: {}),
|
|
251
|
+
}, { opIndex });
|
|
252
|
+
const storedRow = toValidateRow(table.columns, storedValues);
|
|
253
|
+
return {
|
|
254
|
+
kind: 'applied',
|
|
255
|
+
changes: [
|
|
256
|
+
{
|
|
257
|
+
table: op.table,
|
|
258
|
+
rowId: op.rowId,
|
|
259
|
+
op: 'delete',
|
|
260
|
+
scopes: stored.scopes,
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
table: op.table,
|
|
264
|
+
rowId: op.rowId,
|
|
265
|
+
op: 'upsert',
|
|
266
|
+
rowVersion: stored.serverVersion,
|
|
267
|
+
scopes: extracted.scopes,
|
|
268
|
+
payload,
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
operations: [
|
|
272
|
+
{
|
|
182
273
|
opIndex,
|
|
183
274
|
op: 'delete',
|
|
184
275
|
table: table.name,
|
|
185
276
|
rowId: op.rowId,
|
|
186
277
|
row: undefined,
|
|
187
|
-
stored:
|
|
278
|
+
stored: storedRow,
|
|
279
|
+
storedServerVersion: stored.serverVersion,
|
|
188
280
|
},
|
|
281
|
+
{
|
|
282
|
+
opIndex,
|
|
283
|
+
op: 'upsert',
|
|
284
|
+
table: table.name,
|
|
285
|
+
rowId: op.rowId,
|
|
286
|
+
row: toValidateRow(table.columns, values),
|
|
287
|
+
stored: storedRow,
|
|
288
|
+
storedServerVersion: stored.serverVersion,
|
|
289
|
+
nextServerVersion: stored.serverVersion,
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
staged: {
|
|
293
|
+
table,
|
|
294
|
+
op: 'upsert',
|
|
295
|
+
rowId: op.rowId,
|
|
296
|
+
opIndex,
|
|
297
|
+
values,
|
|
298
|
+
stored,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (op.op === 'delete') {
|
|
303
|
+
if (stored === undefined) {
|
|
304
|
+
// Deleting an absent row is applied (idempotent, §6.2); no change.
|
|
305
|
+
return {
|
|
306
|
+
kind: 'applied',
|
|
307
|
+
changes: [],
|
|
308
|
+
operations: [
|
|
309
|
+
{
|
|
310
|
+
opIndex,
|
|
311
|
+
op: 'delete',
|
|
312
|
+
table: table.name,
|
|
313
|
+
rowId: op.rowId,
|
|
314
|
+
row: undefined,
|
|
315
|
+
stored: undefined,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
189
318
|
staged: {
|
|
190
319
|
table,
|
|
191
320
|
op: 'delete',
|
|
@@ -212,21 +341,25 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
212
341
|
await tx.deleteRow(op.table, op.rowId);
|
|
213
342
|
return {
|
|
214
343
|
kind: 'applied',
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
344
|
+
changes: [
|
|
345
|
+
{
|
|
346
|
+
table: op.table,
|
|
347
|
+
rowId: op.rowId,
|
|
348
|
+
op: 'delete',
|
|
349
|
+
scopes: stored.scopes,
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
operations: [
|
|
353
|
+
{
|
|
354
|
+
opIndex,
|
|
355
|
+
op: 'delete',
|
|
356
|
+
table: table.name,
|
|
357
|
+
rowId: op.rowId,
|
|
358
|
+
row: undefined,
|
|
359
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
360
|
+
storedServerVersion: stored.serverVersion,
|
|
361
|
+
},
|
|
362
|
+
],
|
|
230
363
|
staged: { table, op: 'delete', rowId: op.rowId, opIndex, stored },
|
|
231
364
|
};
|
|
232
365
|
}
|
|
@@ -354,24 +487,28 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
354
487
|
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
355
488
|
return {
|
|
356
489
|
kind: 'applied',
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
490
|
+
changes: [
|
|
491
|
+
{
|
|
492
|
+
table: op.table,
|
|
493
|
+
rowId: op.rowId,
|
|
494
|
+
op: 'upsert',
|
|
495
|
+
rowVersion: newVersion,
|
|
496
|
+
scopes: stored.scopes,
|
|
497
|
+
payload: newPayload,
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
operations: [
|
|
501
|
+
{
|
|
502
|
+
opIndex,
|
|
503
|
+
op: 'upsert',
|
|
504
|
+
table: table.name,
|
|
505
|
+
rowId: op.rowId,
|
|
506
|
+
row: toValidateRow(table.columns, values),
|
|
507
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
508
|
+
storedServerVersion: stored.serverVersion,
|
|
509
|
+
nextServerVersion: newVersion,
|
|
510
|
+
},
|
|
511
|
+
],
|
|
375
512
|
staged: { table, op: 'upsert', rowId: op.rowId, opIndex, values, stored },
|
|
376
513
|
};
|
|
377
514
|
}
|
|
@@ -440,23 +577,27 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
440
577
|
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
441
578
|
return {
|
|
442
579
|
kind: 'applied',
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
580
|
+
changes: [
|
|
581
|
+
{
|
|
582
|
+
table: op.table,
|
|
583
|
+
rowId: op.rowId,
|
|
584
|
+
op: 'upsert',
|
|
585
|
+
rowVersion: 1,
|
|
586
|
+
scopes: extracted.scopes,
|
|
587
|
+
payload: insertPayload,
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
operations: [
|
|
591
|
+
{
|
|
592
|
+
opIndex,
|
|
593
|
+
op: 'upsert',
|
|
594
|
+
table: table.name,
|
|
595
|
+
rowId: op.rowId,
|
|
596
|
+
row: toValidateRow(table.columns, values),
|
|
597
|
+
stored: undefined,
|
|
598
|
+
nextServerVersion: 1,
|
|
599
|
+
},
|
|
600
|
+
],
|
|
460
601
|
staged: {
|
|
461
602
|
table,
|
|
462
603
|
op: 'upsert',
|
|
@@ -967,10 +1108,9 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
967
1108
|
break;
|
|
968
1109
|
}
|
|
969
1110
|
results.push({ opIndex, status: 'applied' });
|
|
970
|
-
validatedOperations.push(outcome.
|
|
1111
|
+
validatedOperations.push(...outcome.operations);
|
|
971
1112
|
stagedWrites.push(outcome.staged);
|
|
972
|
-
|
|
973
|
-
changes.push(outcome.change);
|
|
1113
|
+
changes.push(...outcome.changes);
|
|
974
1114
|
}
|
|
975
1115
|
// §6.11: reference enforcement runs after every client operation is
|
|
976
1116
|
// staged (so the candidate state is final for the client's own writes)
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { retainRollbackFailure, rollbackAfterError } from './storage-errors.js';
|
|
2
2
|
import { validateCommitPruneQuery } from './prune.js';
|
|
3
3
|
import { StorageQueryError } from './storage-errors.js';
|
|
4
|
-
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal, } from './storage.js';
|
|
4
|
+
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, rowsLiveAfterDelete, serveGateRefusal, } from './storage.js';
|
|
5
5
|
import { serveNotReadyError } from './readiness.js';
|
|
6
6
|
/**
|
|
7
7
|
* SQLite server storage over the shared synchronous driver.
|
|
@@ -186,11 +186,14 @@ class SqliteTransaction {
|
|
|
186
186
|
const commitSeq = (row?.max_commit_seq ?? 0) + 1;
|
|
187
187
|
db.query('UPDATE sync_partitions SET max_commit_seq=? WHERE partition=?').run(commitSeq, p);
|
|
188
188
|
db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version) VALUES (?,?,?,?,?,?,?)').run(p, commitSeq, commit.clientId, commit.clientCommitId, commit.actorId, commit.createdAtMs, this.#writerVersion);
|
|
189
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
189
190
|
commit.changes.forEach((change, idx) => {
|
|
190
191
|
db.query('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)').run(p, commitSeq, idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
|
|
191
|
-
if (change.op === 'delete'
|
|
192
|
+
if (change.op === 'delete' &&
|
|
193
|
+
!liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)) {
|
|
192
194
|
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
193
|
-
// pruned with the commit log (§4.6).
|
|
195
|
+
// pruned with the commit log (§4.6). A delete that the same commit
|
|
196
|
+
// follows with an upsert is a §2.2 scope move, not a deletion.
|
|
194
197
|
db.query(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
195
198
|
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`).run(p, change.table, change.rowId, commitSeq);
|
|
196
199
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -128,6 +128,13 @@ export interface NewCommit {
|
|
|
128
128
|
readonly createdAtMs: number;
|
|
129
129
|
readonly changes: readonly NewChange[];
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* §2.2: rows whose final change in this commit is an upsert that followed a
|
|
133
|
+
* delete. That delete is one half of a scope move, so it MUST NOT leave a
|
|
134
|
+
* tombstone behind the live row. A row whose final change is a delete keeps
|
|
135
|
+
* its tombstone.
|
|
136
|
+
*/
|
|
137
|
+
export declare function rowsLiveAfterDelete(changes: readonly NewChange[]): Set<string>;
|
|
131
138
|
export interface StoredChange {
|
|
132
139
|
readonly table: string;
|
|
133
140
|
readonly rowId: string;
|
package/dist/storage.js
CHANGED
|
@@ -38,6 +38,32 @@ export function serveGateTokenChanged(before, after) {
|
|
|
38
38
|
}
|
|
39
39
|
return undefined;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* §2.2: rows whose final change in this commit is an upsert that followed a
|
|
43
|
+
* delete. That delete is one half of a scope move, so it MUST NOT leave a
|
|
44
|
+
* tombstone behind the live row. A row whose final change is a delete keeps
|
|
45
|
+
* its tombstone.
|
|
46
|
+
*/
|
|
47
|
+
export function rowsLiveAfterDelete(changes) {
|
|
48
|
+
const last = new Map();
|
|
49
|
+
const deleted = new Set();
|
|
50
|
+
const live = new Set();
|
|
51
|
+
for (const change of changes) {
|
|
52
|
+
const key = `${change.table}\u0000${change.rowId}`;
|
|
53
|
+
if (change.op === 'delete') {
|
|
54
|
+
deleted.add(key);
|
|
55
|
+
}
|
|
56
|
+
else if (deleted.has(key)) {
|
|
57
|
+
live.add(key);
|
|
58
|
+
}
|
|
59
|
+
last.set(key, change.op);
|
|
60
|
+
}
|
|
61
|
+
for (const key of live) {
|
|
62
|
+
if (last.get(key) !== 'upsert')
|
|
63
|
+
live.delete(key);
|
|
64
|
+
}
|
|
65
|
+
return live;
|
|
66
|
+
}
|
|
41
67
|
/** The round-state fields of a stored `sync_clients` row. */
|
|
42
68
|
export function clientRoundState(lastRoundAtMs, lastErrorCode, consecutiveFailures) {
|
|
43
69
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.43.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/d1-storage.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune';
|
|
2
2
|
import { StorageQueryError } from './storage-errors';
|
|
3
3
|
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clientRoundState,
|
|
6
|
+
PARTITION_TOUCH_INTERVAL_MS,
|
|
7
|
+
rowsLiveAfterDelete,
|
|
8
|
+
} from './storage';
|
|
5
9
|
/**
|
|
6
10
|
* Cloudflare D1 server storage for Workers deployments.
|
|
7
11
|
*
|
|
@@ -790,6 +794,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
790
794
|
commit.createdAtMs,
|
|
791
795
|
],
|
|
792
796
|
);
|
|
797
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
793
798
|
commit.changes.forEach((change, idx) => {
|
|
794
799
|
this.#buffer_(
|
|
795
800
|
'INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
@@ -805,9 +810,13 @@ class D1Transaction implements StorageTransaction {
|
|
|
805
810
|
change.payload ?? null,
|
|
806
811
|
],
|
|
807
812
|
);
|
|
808
|
-
if (
|
|
813
|
+
if (
|
|
814
|
+
change.op === 'delete' &&
|
|
815
|
+
!liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)
|
|
816
|
+
) {
|
|
809
817
|
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
810
|
-
// pruned with the commit log (§4.6).
|
|
818
|
+
// pruned with the commit log (§4.6). A delete that the same commit
|
|
819
|
+
// follows with an upsert is a §2.2 scope move, not a deletion.
|
|
811
820
|
this.#buffer_(
|
|
812
821
|
`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
813
822
|
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
|
package/src/operations.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
decodeRemoteOperationRequest,
|
|
8
8
|
encodeRemoteOperationResponse,
|
|
9
9
|
encodeSparseRow,
|
|
10
|
-
type PushOperation,
|
|
11
10
|
type RemoteOperationRequest,
|
|
12
11
|
type RemoteOperationResponse,
|
|
13
12
|
type RowValue,
|
|
@@ -20,7 +19,7 @@ import {
|
|
|
20
19
|
touchAuthenticatedPartition,
|
|
21
20
|
} from './context';
|
|
22
21
|
import { reportError, SyncError, syncError } from './errors';
|
|
23
|
-
import { processPushOperationsWithTrace } from './push';
|
|
22
|
+
import { processPushOperationsWithTrace, type ApplyOperation } from './push';
|
|
24
23
|
import { compileSchema } from './schema';
|
|
25
24
|
import {
|
|
26
25
|
authorizeWrite,
|
|
@@ -108,6 +107,12 @@ export type CommandMutation =
|
|
|
108
107
|
readonly op: 'upsert';
|
|
109
108
|
readonly values: Readonly<Record<string, RowValue>>;
|
|
110
109
|
}
|
|
110
|
+
| {
|
|
111
|
+
readonly table: string;
|
|
112
|
+
readonly op: 'rescope';
|
|
113
|
+
/** Full row; only its scope columns may differ from the stored row. */
|
|
114
|
+
readonly values: Readonly<Record<string, RowValue>>;
|
|
115
|
+
}
|
|
111
116
|
| {
|
|
112
117
|
readonly table: string;
|
|
113
118
|
readonly op: 'delete';
|
|
@@ -501,7 +506,7 @@ export function remoteCommand<Input = undefined>(
|
|
|
501
506
|
function commandOperations(
|
|
502
507
|
mutations: readonly CommandMutation[],
|
|
503
508
|
schema: ReturnType<typeof compileSchema>,
|
|
504
|
-
):
|
|
509
|
+
): ApplyOperation[] {
|
|
505
510
|
if (mutations.length === 0) {
|
|
506
511
|
throw syncError(
|
|
507
512
|
'operation.invalid_request',
|
|
@@ -530,7 +535,7 @@ function commandOperations(
|
|
|
530
535
|
if (!table.columnIndex.has(name)) {
|
|
531
536
|
throw syncError(
|
|
532
537
|
'operation.invalid_request',
|
|
533
|
-
'command upsert has an unknown column',
|
|
538
|
+
'command upsert or rescope has an unknown column',
|
|
534
539
|
);
|
|
535
540
|
}
|
|
536
541
|
}
|
|
@@ -538,7 +543,7 @@ function commandOperations(
|
|
|
538
543
|
if (!supplied.has(column.name)) {
|
|
539
544
|
throw syncError(
|
|
540
545
|
'operation.invalid_request',
|
|
541
|
-
'command upsert must provide a full row',
|
|
546
|
+
'command upsert or rescope must provide a full row',
|
|
542
547
|
);
|
|
543
548
|
}
|
|
544
549
|
return mutation.values[column.name] ?? null;
|
|
@@ -547,13 +552,13 @@ function commandOperations(
|
|
|
547
552
|
if (rowId === undefined) {
|
|
548
553
|
throw syncError(
|
|
549
554
|
'operation.invalid_request',
|
|
550
|
-
'command upsert requires a non-empty primary key',
|
|
555
|
+
'command upsert or rescope requires a non-empty primary key',
|
|
551
556
|
);
|
|
552
557
|
}
|
|
553
558
|
return {
|
|
554
559
|
table: table.name,
|
|
555
560
|
rowId,
|
|
556
|
-
op:
|
|
561
|
+
op: mutation.op,
|
|
557
562
|
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
558
563
|
};
|
|
559
564
|
});
|
package/src/postgres-storage.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
4
4
|
import {
|
|
5
5
|
clientRoundState,
|
|
6
6
|
PARTITION_TOUCH_INTERVAL_MS,
|
|
7
|
+
rowsLiveAfterDelete,
|
|
7
8
|
serveGateRefusal,
|
|
8
9
|
} from './storage';
|
|
9
10
|
import { serveNotReadyError } from './readiness';
|
|
@@ -1402,6 +1403,27 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1402
1403
|
params,
|
|
1403
1404
|
);
|
|
1404
1405
|
}
|
|
1406
|
+
// §2.2: a delete whose commit follows it with an upsert is a scope move.
|
|
1407
|
+
// The row is live, so no tombstone may remain for it.
|
|
1408
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
1409
|
+
if (liveAfterDelete.size > 0) {
|
|
1410
|
+
await q.query(
|
|
1411
|
+
`DELETE FROM sync_tombstones existing
|
|
1412
|
+
USING jsonb_to_recordset($2::text::jsonb) AS moved(tbl text, row_id text)
|
|
1413
|
+
WHERE existing.partition=$1
|
|
1414
|
+
AND existing.tbl=moved.tbl
|
|
1415
|
+
AND existing.row_id=moved.row_id`,
|
|
1416
|
+
[
|
|
1417
|
+
p,
|
|
1418
|
+
JSON.stringify(
|
|
1419
|
+
[...liveAfterDelete].map((key) => {
|
|
1420
|
+
const at = key.indexOf('\u0000');
|
|
1421
|
+
return { tbl: key.slice(0, at), row_id: key.slice(at + 1) };
|
|
1422
|
+
}),
|
|
1423
|
+
),
|
|
1424
|
+
],
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1405
1427
|
return commitSeq;
|
|
1406
1428
|
}
|
|
1407
1429
|
|
package/src/push.ts
CHANGED
|
@@ -108,12 +108,28 @@ type TerminatingOutcome = {
|
|
|
108
108
|
type OperationOutcome =
|
|
109
109
|
| {
|
|
110
110
|
readonly kind: 'applied';
|
|
111
|
-
readonly
|
|
112
|
-
readonly
|
|
111
|
+
readonly changes: readonly NewChange[];
|
|
112
|
+
readonly operations: readonly ValidateCommitOperation[];
|
|
113
113
|
readonly staged: StagedWrite;
|
|
114
114
|
}
|
|
115
115
|
| TerminatingOutcome;
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* A server-authoritative scope move (§2.2). Commands emit it through
|
|
119
|
+
* `CommandMutation`; it is not an SSP2 wire operation, so a client cannot
|
|
120
|
+
* request one. Apply rewrites the stored row in place under the new scopes
|
|
121
|
+
* and emits the delete/upsert change pair in one commit.
|
|
122
|
+
*/
|
|
123
|
+
export interface RescopeOperation {
|
|
124
|
+
readonly table: string;
|
|
125
|
+
readonly rowId: string;
|
|
126
|
+
readonly op: 'rescope';
|
|
127
|
+
/** Full-row payload (the row's values under the new scopes). */
|
|
128
|
+
readonly payload: Uint8Array;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type ApplyOperation = PushOperation | RescopeOperation;
|
|
132
|
+
|
|
117
133
|
function errorRecord(
|
|
118
134
|
opIndex: number,
|
|
119
135
|
code: string,
|
|
@@ -296,7 +312,7 @@ async function applyOperation(
|
|
|
296
312
|
schema: CompiledSchema,
|
|
297
313
|
wireSchema: CompiledSchema,
|
|
298
314
|
resolved: ResolvedScopes,
|
|
299
|
-
op:
|
|
315
|
+
op: ApplyOperation,
|
|
300
316
|
opIndex: number,
|
|
301
317
|
blobCtx: BlobApplyContext,
|
|
302
318
|
crdtMergers: CrdtMergerRegistry | undefined,
|
|
@@ -322,20 +338,224 @@ async function applyOperation(
|
|
|
322
338
|
}
|
|
323
339
|
const stored = await tx.getRow(op.table, op.rowId);
|
|
324
340
|
|
|
325
|
-
if (op.op === '
|
|
341
|
+
if (op.op === 'rescope') {
|
|
342
|
+
// §2.2 explicit scope move. One stored row changes its scope values;
|
|
343
|
+
// apply rewrites it in place, keeps `server_version`, and emits the
|
|
344
|
+
// delete under the old scopes plus the upsert under the new ones.
|
|
326
345
|
if (stored === undefined) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
346
|
+
return errorRecord(
|
|
347
|
+
opIndex,
|
|
348
|
+
'sync.row_missing',
|
|
349
|
+
'rescope targets an absent row (§2.2)',
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (!authorizeWrite(table, stored.scopes, resolved)) {
|
|
353
|
+
return errorRecord(
|
|
354
|
+
opIndex,
|
|
355
|
+
'sync.forbidden',
|
|
356
|
+
'rescope denied by scope authorization (§3.4)',
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const missing = missingScopeVariable(table, stored.scopes);
|
|
360
|
+
if (missing !== undefined) {
|
|
361
|
+
return errorRecord(
|
|
362
|
+
opIndex,
|
|
363
|
+
'sync.missing_scopes',
|
|
364
|
+
`stored row lacks scope variable ${JSON.stringify(missing)} (§3.1)`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
let sparse: SparseRowValue[];
|
|
368
|
+
try {
|
|
369
|
+
sparse = decodeSparseRow(
|
|
370
|
+
wireTable.columns,
|
|
371
|
+
wireTable.primaryKeyIndex,
|
|
372
|
+
op.payload,
|
|
373
|
+
);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return errorRecord(
|
|
376
|
+
opIndex,
|
|
377
|
+
'sync.invalid_request',
|
|
378
|
+
`rescope payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (
|
|
382
|
+
sparse.length < table.columns.length ||
|
|
383
|
+
sparse.some((value) => value === undefined)
|
|
384
|
+
) {
|
|
385
|
+
return errorRecord(
|
|
386
|
+
opIndex,
|
|
387
|
+
'sync.invalid_request',
|
|
388
|
+
'rescope requires a full-row payload (§2.2)',
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const values: RowValue[] = sparse.map((value) => value ?? null);
|
|
392
|
+
const pkValue = renderScopeValue(values[table.primaryKeyIndex] ?? null);
|
|
393
|
+
if (pkValue !== op.rowId) {
|
|
394
|
+
return errorRecord(
|
|
395
|
+
opIndex,
|
|
396
|
+
'sync.invalid_request',
|
|
397
|
+
'payload primary key does not match rowId',
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const storedValues = decodeRow(table.columns, stored.payload);
|
|
401
|
+
const scopeIndexes = new Set(
|
|
402
|
+
table.scopePatterns.map((pattern) => pattern.columnIndex),
|
|
403
|
+
);
|
|
404
|
+
const sameValue = (left: RowValue, right: RowValue): boolean => {
|
|
405
|
+
if (left instanceof Uint8Array || right instanceof Uint8Array) {
|
|
406
|
+
return (
|
|
407
|
+
left instanceof Uint8Array &&
|
|
408
|
+
right instanceof Uint8Array &&
|
|
409
|
+
left.length === right.length &&
|
|
410
|
+
left.every((byte, index) => byte === right[index])
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
return left === right;
|
|
414
|
+
};
|
|
415
|
+
for (let index = 0; index < table.columns.length; index++) {
|
|
416
|
+
if (index === table.primaryKeyIndex || scopeIndexes.has(index)) continue;
|
|
417
|
+
if (!sameValue(values[index] ?? null, storedValues[index] ?? null)) {
|
|
418
|
+
return errorRecord(
|
|
419
|
+
opIndex,
|
|
420
|
+
'sync.invalid_request',
|
|
421
|
+
'rescope may change only scope columns (§2.2)',
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const extracted = storedScopesForRow(table, values);
|
|
426
|
+
if ('missing' in extracted) {
|
|
427
|
+
return errorRecord(
|
|
428
|
+
opIndex,
|
|
429
|
+
'sync.forbidden',
|
|
430
|
+
`rescope missing scope column value for ${JSON.stringify(extracted.missing)} (§3.4)`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (!authorizeWrite(table, extracted.scopes, resolved)) {
|
|
434
|
+
return errorRecord(
|
|
435
|
+
opIndex,
|
|
436
|
+
'sync.forbidden',
|
|
437
|
+
'rescope denied by scope authorization of the new scopes (§3.4)',
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const deleteReject = await runValidator(
|
|
441
|
+
validators,
|
|
442
|
+
table,
|
|
443
|
+
'delete',
|
|
444
|
+
op.rowId,
|
|
445
|
+
undefined,
|
|
446
|
+
storedValues,
|
|
447
|
+
opIndex,
|
|
448
|
+
tx,
|
|
449
|
+
partition,
|
|
450
|
+
actorId,
|
|
451
|
+
);
|
|
452
|
+
if (deleteReject !== undefined) return deleteReject;
|
|
453
|
+
const upsertReject = await runValidator(
|
|
454
|
+
validators,
|
|
455
|
+
table,
|
|
456
|
+
'upsert',
|
|
457
|
+
op.rowId,
|
|
458
|
+
values,
|
|
459
|
+
storedValues,
|
|
460
|
+
opIndex,
|
|
461
|
+
tx,
|
|
462
|
+
partition,
|
|
463
|
+
actorId,
|
|
464
|
+
);
|
|
465
|
+
if (upsertReject !== undefined) return upsertReject;
|
|
466
|
+
const blobCheck = await checkAndRecordBlobs(
|
|
467
|
+
tx,
|
|
468
|
+
table,
|
|
469
|
+
op.rowId,
|
|
470
|
+
values,
|
|
471
|
+
opIndex,
|
|
472
|
+
blobCtx,
|
|
473
|
+
);
|
|
474
|
+
if (blobCheck !== undefined) return blobCheck;
|
|
475
|
+
const payload = encodeRow(table.columns, values);
|
|
476
|
+
// The move clears any tombstone from an earlier incarnation: the row is
|
|
477
|
+
// live at the end of this commit, so the next move must not hit §5.2.
|
|
478
|
+
await tx.clearTombstone(op.table, op.rowId);
|
|
479
|
+
await tx.upsertRow(
|
|
480
|
+
op.table,
|
|
481
|
+
{
|
|
482
|
+
rowId: op.rowId,
|
|
483
|
+
serverVersion: stored.serverVersion,
|
|
484
|
+
scopes: extracted.scopes,
|
|
485
|
+
payload,
|
|
486
|
+
...(stored.columnVersions !== undefined
|
|
487
|
+
? { columnVersions: stored.columnVersions }
|
|
488
|
+
: {}),
|
|
489
|
+
},
|
|
490
|
+
{ opIndex },
|
|
491
|
+
);
|
|
492
|
+
const storedRow = toValidateRow(table.columns, storedValues);
|
|
493
|
+
return {
|
|
494
|
+
kind: 'applied',
|
|
495
|
+
changes: [
|
|
496
|
+
{
|
|
497
|
+
table: op.table,
|
|
498
|
+
rowId: op.rowId,
|
|
499
|
+
op: 'delete',
|
|
500
|
+
scopes: stored.scopes,
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
table: op.table,
|
|
504
|
+
rowId: op.rowId,
|
|
505
|
+
op: 'upsert',
|
|
506
|
+
rowVersion: stored.serverVersion,
|
|
507
|
+
scopes: extracted.scopes,
|
|
508
|
+
payload,
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
operations: [
|
|
512
|
+
{
|
|
332
513
|
opIndex,
|
|
333
514
|
op: 'delete',
|
|
334
515
|
table: table.name,
|
|
335
516
|
rowId: op.rowId,
|
|
336
517
|
row: undefined,
|
|
337
|
-
stored:
|
|
518
|
+
stored: storedRow,
|
|
519
|
+
storedServerVersion: stored.serverVersion,
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
opIndex,
|
|
523
|
+
op: 'upsert',
|
|
524
|
+
table: table.name,
|
|
525
|
+
rowId: op.rowId,
|
|
526
|
+
row: toValidateRow(table.columns, values),
|
|
527
|
+
stored: storedRow,
|
|
528
|
+
storedServerVersion: stored.serverVersion,
|
|
529
|
+
nextServerVersion: stored.serverVersion,
|
|
338
530
|
},
|
|
531
|
+
],
|
|
532
|
+
staged: {
|
|
533
|
+
table,
|
|
534
|
+
op: 'upsert',
|
|
535
|
+
rowId: op.rowId,
|
|
536
|
+
opIndex,
|
|
537
|
+
values,
|
|
538
|
+
stored,
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (op.op === 'delete') {
|
|
544
|
+
if (stored === undefined) {
|
|
545
|
+
// Deleting an absent row is applied (idempotent, §6.2); no change.
|
|
546
|
+
return {
|
|
547
|
+
kind: 'applied',
|
|
548
|
+
changes: [],
|
|
549
|
+
operations: [
|
|
550
|
+
{
|
|
551
|
+
opIndex,
|
|
552
|
+
op: 'delete',
|
|
553
|
+
table: table.name,
|
|
554
|
+
rowId: op.rowId,
|
|
555
|
+
row: undefined,
|
|
556
|
+
stored: undefined,
|
|
557
|
+
},
|
|
558
|
+
],
|
|
339
559
|
staged: {
|
|
340
560
|
table,
|
|
341
561
|
op: 'delete',
|
|
@@ -380,21 +600,25 @@ async function applyOperation(
|
|
|
380
600
|
await tx.deleteRow(op.table, op.rowId);
|
|
381
601
|
return {
|
|
382
602
|
kind: 'applied',
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
603
|
+
changes: [
|
|
604
|
+
{
|
|
605
|
+
table: op.table,
|
|
606
|
+
rowId: op.rowId,
|
|
607
|
+
op: 'delete',
|
|
608
|
+
scopes: stored.scopes,
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
operations: [
|
|
612
|
+
{
|
|
613
|
+
opIndex,
|
|
614
|
+
op: 'delete',
|
|
615
|
+
table: table.name,
|
|
616
|
+
rowId: op.rowId,
|
|
617
|
+
row: undefined,
|
|
618
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
619
|
+
storedServerVersion: stored.serverVersion,
|
|
620
|
+
},
|
|
621
|
+
],
|
|
398
622
|
staged: { table, op: 'delete', rowId: op.rowId, opIndex, stored },
|
|
399
623
|
};
|
|
400
624
|
}
|
|
@@ -586,24 +810,28 @@ async function applyOperation(
|
|
|
586
810
|
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
587
811
|
return {
|
|
588
812
|
kind: 'applied',
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
813
|
+
changes: [
|
|
814
|
+
{
|
|
815
|
+
table: op.table,
|
|
816
|
+
rowId: op.rowId,
|
|
817
|
+
op: 'upsert',
|
|
818
|
+
rowVersion: newVersion,
|
|
819
|
+
scopes: stored.scopes,
|
|
820
|
+
payload: newPayload,
|
|
821
|
+
},
|
|
822
|
+
],
|
|
823
|
+
operations: [
|
|
824
|
+
{
|
|
825
|
+
opIndex,
|
|
826
|
+
op: 'upsert',
|
|
827
|
+
table: table.name,
|
|
828
|
+
rowId: op.rowId,
|
|
829
|
+
row: toValidateRow(table.columns, values),
|
|
830
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
831
|
+
storedServerVersion: stored.serverVersion,
|
|
832
|
+
nextServerVersion: newVersion,
|
|
833
|
+
},
|
|
834
|
+
],
|
|
607
835
|
staged: { table, op: 'upsert', rowId: op.rowId, opIndex, values, stored },
|
|
608
836
|
};
|
|
609
837
|
}
|
|
@@ -721,23 +949,27 @@ async function applyOperation(
|
|
|
721
949
|
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
722
950
|
return {
|
|
723
951
|
kind: 'applied',
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
952
|
+
changes: [
|
|
953
|
+
{
|
|
954
|
+
table: op.table,
|
|
955
|
+
rowId: op.rowId,
|
|
956
|
+
op: 'upsert',
|
|
957
|
+
rowVersion: 1,
|
|
958
|
+
scopes: extracted.scopes,
|
|
959
|
+
payload: insertPayload,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
operations: [
|
|
963
|
+
{
|
|
964
|
+
opIndex,
|
|
965
|
+
op: 'upsert',
|
|
966
|
+
table: table.name,
|
|
967
|
+
rowId: op.rowId,
|
|
968
|
+
row: toValidateRow(table.columns, values),
|
|
969
|
+
stored: undefined,
|
|
970
|
+
nextServerVersion: 1,
|
|
971
|
+
},
|
|
972
|
+
],
|
|
741
973
|
staged: {
|
|
742
974
|
table,
|
|
743
975
|
op: 'upsert',
|
|
@@ -1366,7 +1598,7 @@ export async function processPushOperationsWithTrace(
|
|
|
1366
1598
|
clientCommitId: string,
|
|
1367
1599
|
buildOperations: (
|
|
1368
1600
|
tx: StorageTransaction,
|
|
1369
|
-
) => Promise<readonly
|
|
1601
|
+
) => Promise<readonly ApplyOperation[]>,
|
|
1370
1602
|
wireSchema: CompiledSchema = schema,
|
|
1371
1603
|
): Promise<ProcessedPushCommit> {
|
|
1372
1604
|
const { storage, partition } = ctx;
|
|
@@ -1495,9 +1727,9 @@ export async function processPushOperationsWithTrace(
|
|
|
1495
1727
|
break;
|
|
1496
1728
|
}
|
|
1497
1729
|
results.push({ opIndex, status: 'applied' });
|
|
1498
|
-
validatedOperations.push(outcome.
|
|
1730
|
+
validatedOperations.push(...outcome.operations);
|
|
1499
1731
|
stagedWrites.push(outcome.staged);
|
|
1500
|
-
|
|
1732
|
+
changes.push(...outcome.changes);
|
|
1501
1733
|
}
|
|
1502
1734
|
|
|
1503
1735
|
// §6.11: reference enforcement runs after every client operation is
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
5
5
|
import {
|
|
6
6
|
clientRoundState,
|
|
7
7
|
PARTITION_TOUCH_INTERVAL_MS,
|
|
8
|
+
rowsLiveAfterDelete,
|
|
8
9
|
serveGateRefusal,
|
|
9
10
|
} from './storage';
|
|
10
11
|
import { serveNotReadyError } from './readiness';
|
|
@@ -414,6 +415,7 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
414
415
|
commit.createdAtMs,
|
|
415
416
|
this.#writerVersion,
|
|
416
417
|
);
|
|
418
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
417
419
|
commit.changes.forEach((change, idx) => {
|
|
418
420
|
db.query(
|
|
419
421
|
'INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
@@ -428,9 +430,13 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
428
430
|
JSON.stringify(change.scopes),
|
|
429
431
|
change.payload ?? null,
|
|
430
432
|
);
|
|
431
|
-
if (
|
|
433
|
+
if (
|
|
434
|
+
change.op === 'delete' &&
|
|
435
|
+
!liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)
|
|
436
|
+
) {
|
|
432
437
|
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
433
|
-
// pruned with the commit log (§4.6).
|
|
438
|
+
// pruned with the commit log (§4.6). A delete that the same commit
|
|
439
|
+
// follows with an upsert is a §2.2 scope move, not a deletion.
|
|
434
440
|
db.query(
|
|
435
441
|
`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
436
442
|
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
|
package/src/storage.ts
CHANGED
|
@@ -182,6 +182,33 @@ export interface NewCommit {
|
|
|
182
182
|
readonly changes: readonly NewChange[];
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* §2.2: rows whose final change in this commit is an upsert that followed a
|
|
187
|
+
* delete. That delete is one half of a scope move, so it MUST NOT leave a
|
|
188
|
+
* tombstone behind the live row. A row whose final change is a delete keeps
|
|
189
|
+
* its tombstone.
|
|
190
|
+
*/
|
|
191
|
+
export function rowsLiveAfterDelete(
|
|
192
|
+
changes: readonly NewChange[],
|
|
193
|
+
): Set<string> {
|
|
194
|
+
const last = new Map<string, NewChange['op']>();
|
|
195
|
+
const deleted = new Set<string>();
|
|
196
|
+
const live = new Set<string>();
|
|
197
|
+
for (const change of changes) {
|
|
198
|
+
const key = `${change.table}\u0000${change.rowId}`;
|
|
199
|
+
if (change.op === 'delete') {
|
|
200
|
+
deleted.add(key);
|
|
201
|
+
} else if (deleted.has(key)) {
|
|
202
|
+
live.add(key);
|
|
203
|
+
}
|
|
204
|
+
last.set(key, change.op);
|
|
205
|
+
}
|
|
206
|
+
for (const key of live) {
|
|
207
|
+
if (last.get(key) !== 'upsert') live.delete(key);
|
|
208
|
+
}
|
|
209
|
+
return live;
|
|
210
|
+
}
|
|
211
|
+
|
|
185
212
|
export interface StoredChange {
|
|
186
213
|
readonly table: string;
|
|
187
214
|
readonly rowId: string;
|