@syncular/server 0.42.1 → 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.
@@ -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
  }
@@ -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';
@@ -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: 'upsert',
275
+ op: mutation.op,
276
276
  payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
277
277
  };
278
278
  });
@@ -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 PushOperation[]>, wireSchema?: CompiledSchema): Promise<ProcessedPushCommit>;
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 === 'delete') {
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
- // Deleting an absent row is applied (idempotent, §6.2); no change.
178
- return {
179
- kind: 'applied',
180
- change: undefined,
181
- operation: {
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: undefined,
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
- change: {
216
- table: op.table,
217
- rowId: op.rowId,
218
- op: 'delete',
219
- scopes: stored.scopes,
220
- },
221
- operation: {
222
- opIndex,
223
- op: 'delete',
224
- table: table.name,
225
- rowId: op.rowId,
226
- row: undefined,
227
- stored: toValidateRow(table.columns, storedValues),
228
- storedServerVersion: stored.serverVersion,
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
- change: {
358
- table: op.table,
359
- rowId: op.rowId,
360
- op: 'upsert',
361
- rowVersion: newVersion,
362
- scopes: stored.scopes,
363
- payload: newPayload,
364
- },
365
- operation: {
366
- opIndex,
367
- op: 'upsert',
368
- table: table.name,
369
- rowId: op.rowId,
370
- row: toValidateRow(table.columns, values),
371
- stored: toValidateRow(table.columns, storedValues),
372
- storedServerVersion: stored.serverVersion,
373
- nextServerVersion: newVersion,
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
- change: {
444
- table: op.table,
445
- rowId: op.rowId,
446
- op: 'upsert',
447
- rowVersion: 1,
448
- scopes: extracted.scopes,
449
- payload: insertPayload,
450
- },
451
- operation: {
452
- opIndex,
453
- op: 'upsert',
454
- table: table.name,
455
- rowId: op.rowId,
456
- row: toValidateRow(table.columns, values),
457
- stored: undefined,
458
- nextServerVersion: 1,
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.operation);
1111
+ validatedOperations.push(...outcome.operations);
971
1112
  stagedWrites.push(outcome.staged);
972
- if (outcome.change !== undefined)
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)
@@ -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.42.1",
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.42.1"
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 { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage';
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 (change.op === 'delete') {
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
- ): PushOperation[] {
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: 'upsert',
561
+ op: mutation.op,
557
562
  payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
558
563
  };
559
564
  });
@@ -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 change: NewChange | undefined;
112
- readonly operation: ValidateCommitOperation;
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: PushOperation,
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 === 'delete') {
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
- // Deleting an absent row is applied (idempotent, §6.2); no change.
328
- return {
329
- kind: 'applied',
330
- change: undefined,
331
- operation: {
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: undefined,
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
- change: {
384
- table: op.table,
385
- rowId: op.rowId,
386
- op: 'delete',
387
- scopes: stored.scopes,
388
- },
389
- operation: {
390
- opIndex,
391
- op: 'delete',
392
- table: table.name,
393
- rowId: op.rowId,
394
- row: undefined,
395
- stored: toValidateRow(table.columns, storedValues),
396
- storedServerVersion: stored.serverVersion,
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
- change: {
590
- table: op.table,
591
- rowId: op.rowId,
592
- op: 'upsert',
593
- rowVersion: newVersion,
594
- scopes: stored.scopes,
595
- payload: newPayload,
596
- },
597
- operation: {
598
- opIndex,
599
- op: 'upsert',
600
- table: table.name,
601
- rowId: op.rowId,
602
- row: toValidateRow(table.columns, values),
603
- stored: toValidateRow(table.columns, storedValues),
604
- storedServerVersion: stored.serverVersion,
605
- nextServerVersion: newVersion,
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
- change: {
725
- table: op.table,
726
- rowId: op.rowId,
727
- op: 'upsert',
728
- rowVersion: 1,
729
- scopes: extracted.scopes,
730
- payload: insertPayload,
731
- },
732
- operation: {
733
- opIndex,
734
- op: 'upsert',
735
- table: table.name,
736
- rowId: op.rowId,
737
- row: toValidateRow(table.columns, values),
738
- stored: undefined,
739
- nextServerVersion: 1,
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 PushOperation[]>,
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.operation);
1730
+ validatedOperations.push(...outcome.operations);
1499
1731
  stagedWrites.push(outcome.staged);
1500
- if (outcome.change !== undefined) changes.push(outcome.change);
1732
+ changes.push(...outcome.changes);
1501
1733
  }
1502
1734
 
1503
1735
  // §6.11: reference enforcement runs after every client operation is
@@ -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 (change.op === 'delete') {
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;