@syncular/server 0.42.2 → 0.44.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
  *
@@ -308,7 +308,7 @@ class D1Transaction {
308
308
  }
309
309
  return queryAuthoritativeOn(this.#db, this.#partition, this.#prepareQuery(query));
310
310
  }
311
- async lockPartitionForPush() {
311
+ async preparePushApply(clientId, clientCommitId, runningSchemaVersion, _targets = []) {
312
312
  this.#assertOpen();
313
313
  if (!this.#pushApplySerialized) {
314
314
  throw new Error('D1 push apply requires externally serialized partition writes');
@@ -316,6 +316,10 @@ class D1Transaction {
316
316
  // D1 has no interactive lock. The caller explicitly asserted that every
317
317
  // write for this partition is already serialized (normally by its DO).
318
318
  this.#pushApplyCheckpoint = this.#buffer.length;
319
+ return {
320
+ gate: await this.readServeGate(runningSchemaVersion),
321
+ persisted: await this.getPushResult(clientId, clientCommitId),
322
+ };
319
323
  }
320
324
  advanceCheckpoint(_name, _ownerEpoch, _watermark, _observedRows, _nowMs) {
321
325
  // D1 installs no fence and declares no checkpoints in this release.
@@ -345,6 +349,9 @@ class D1Transaction {
345
349
  this.#applicationOpIndexes.clear();
346
350
  await this.putPushResult(clientId, clientCommitId, result);
347
351
  await this.commit();
352
+ // The idempotency re-check found no record under the serialized apply, so
353
+ // the insert above is the canonical outcome.
354
+ return result;
348
355
  }
349
356
  async #assertNoUniqueCollision(table, row, opIndex) {
350
357
  if (!table.materialize)
@@ -480,6 +487,7 @@ class D1Transaction {
480
487
  commit.actorId,
481
488
  commit.createdAtMs,
482
489
  ]);
490
+ const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
483
491
  commit.changes.forEach((change, idx) => {
484
492
  this.#buffer_('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)', [
485
493
  p,
@@ -492,9 +500,11 @@ class D1Transaction {
492
500
  JSON.stringify(change.scopes),
493
501
  change.payload ?? null,
494
502
  ]);
495
- if (change.op === 'delete') {
503
+ if (change.op === 'delete' &&
504
+ !liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)) {
496
505
  // §5 delete precedence: every applied delete leaves a tombstone,
497
- // pruned with the commit log (§4.6).
506
+ // pruned with the commit log (§4.6). A delete that the same commit
507
+ // follows with an upsert is a §2.2 scope move, not a deletion.
498
508
  this.#buffer_(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
499
509
  ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`, [p, change.table, change.rowId, commitSeq]);
500
510
  }
@@ -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
  });