@syncular/server 0.20.1 → 0.21.1

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/context.d.ts CHANGED
@@ -61,6 +61,8 @@ export interface ServerLimits {
61
61
  readonly maxOperationsPerRequest: number;
62
62
  /** Inline rows segments up to this size (§5.7 SHOULD: 256 KiB). */
63
63
  readonly inlineSegmentMaxBytes: number;
64
+ /** §6.11 declared-reference cascade cap per commit (reference default). */
65
+ readonly maxCascadeOperationsPerCommit: number;
64
66
  }
65
67
  export declare const DEFAULT_LIMITS: ServerLimits;
66
68
  /** Receives applied commits for realtime fanout (§8.2). */
package/dist/context.js CHANGED
@@ -14,6 +14,7 @@ export const RESOLVER_OUTAGE = Symbol.for('syncular.resolver_outage');
14
14
  export const DEFAULT_LIMITS = {
15
15
  maxOperationsPerRequest: 500,
16
16
  inlineSegmentMaxBytes: 256 * 1024,
17
+ maxCascadeOperationsPerCommit: 1_000,
17
18
  };
18
19
  export function clockOf(ctx) {
19
20
  return ctx.clock ?? Date.now;
@@ -138,6 +138,21 @@ class D1Transaction {
138
138
  .first();
139
139
  return record === null ? undefined : toStoredRow(record);
140
140
  }
141
+ async getTombstoneSeq(table, rowId) {
142
+ this.#assertOpen();
143
+ // Persisted read, no #pending overlay: a delete buffered by this commit
144
+ // records its tombstone at appendCommit, matching the SQLite/Postgres
145
+ // adapters where the tombstone insert also runs at commit append.
146
+ const record = await this.#db
147
+ .prepare('SELECT commit_seq FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?')
148
+ .bind(this.#partition, table, rowId)
149
+ .first();
150
+ return record?.commit_seq;
151
+ }
152
+ async clearTombstone(table, rowId) {
153
+ this.#assertOpen();
154
+ this.#buffer_('DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?', [this.#partition, table, rowId]);
155
+ }
141
156
  async getPushResult(clientId, clientCommitId) {
142
157
  this.#assertOpen();
143
158
  // D1 reads run in autocommit; the push layer's duplicate re-check happens
@@ -405,6 +420,12 @@ class D1Transaction {
405
420
  JSON.stringify(change.scopes),
406
421
  change.payload ?? null,
407
422
  ]);
423
+ if (change.op === 'delete') {
424
+ // §5 delete precedence: every applied delete leaves a tombstone,
425
+ // pruned with the commit log (§4.6).
426
+ this.#buffer_(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
427
+ ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`, [p, change.table, change.rowId, commitSeq]);
428
+ }
408
429
  for (const [variable, value] of Object.entries(change.scopes)) {
409
430
  this.#buffer_('INSERT OR IGNORE INTO sync_change_scopes(partition, tbl, var, value, commit_seq) VALUES (?,?,?,?,?)', [p, change.table, variable, value, commitSeq]);
410
431
  }
@@ -1024,7 +1045,12 @@ export class D1ServerStorage {
1024
1045
  this.#db
1025
1046
  .prepare(`SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`)
1026
1047
  .bind(partition, partition, partition),
1027
- ...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) => this.#db
1048
+ ...[
1049
+ 'sync_commits',
1050
+ 'sync_changes',
1051
+ 'sync_change_scopes',
1052
+ 'sync_tombstones',
1053
+ ].map((table) => this.#db
1028
1054
  .prepare(`DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`)
1029
1055
  .bind(partition, partition, partition, query.logEpoch)),
1030
1056
  ]);
package/dist/errors.js CHANGED
@@ -111,6 +111,22 @@ export const ERROR_CATALOG = {
111
111
  recommendedAction: 'forceResync',
112
112
  httpStatus: 404,
113
113
  },
114
+ // §5 delete precedence: an unversioned upsert lost to a delete inside the
115
+ // tombstone horizon. The client drops the operation on rebuild.
116
+ 'sync.row_deleted': {
117
+ category: 'not-found',
118
+ retryable: false,
119
+ recommendedAction: 'fixRequest',
120
+ httpStatus: 404,
121
+ },
122
+ // §6.11 declared references: absent parent, RESTRICT delete with live
123
+ // children, or the cascade cap. A push operation-result error only.
124
+ 'sync.reference_violation': {
125
+ category: 'invalid-request',
126
+ retryable: false,
127
+ recommendedAction: 'fixRequest',
128
+ httpStatus: 400,
129
+ },
114
130
  'sync.version_conflict': {
115
131
  category: 'conflict',
116
132
  retryable: false,
@@ -191,12 +207,6 @@ export const ERROR_CATALOG = {
191
207
  recommendedAction: 'upgradeClient',
192
208
  httpStatus: 400,
193
209
  },
194
- 'sync.client_wire_unsupported': {
195
- category: 'schema-mismatch',
196
- retryable: false,
197
- recommendedAction: 'upgradeClient',
198
- httpStatus: 400,
199
- },
200
210
  'sync.websocket_connection_limit': {
201
211
  category: 'rate-limited',
202
212
  retryable: true,
@@ -9,13 +9,11 @@
9
9
  * bytes; the reference codec remains the single source of wire bytes.
10
10
  */
11
11
  import { encodeMessage, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
12
- const stubHeader = (wireVersion) => wireVersion >= 2
13
- ? {
14
- type: 'RESP_HEADER',
15
- logEpoch: 'frame-probe',
16
- resetRequired: false,
17
- }
18
- : { type: 'RESP_HEADER' };
12
+ const stubHeader = () => ({
13
+ type: 'RESP_HEADER',
14
+ logEpoch: 'frame-probe',
15
+ resetRequired: false,
16
+ });
19
17
  const STUB_SUB_START = {
20
18
  type: 'SUB_START',
21
19
  id: '',
@@ -26,32 +24,32 @@ const STUB_SUB_START = {
26
24
  };
27
25
  const STUB_SUB_END = { type: 'SUB_END', nextCursor: 0 };
28
26
  const probe = encodeMessage({
29
- wireVersion: 1,
27
+ wireVersion: PROTOCOL_WIRE_VERSION,
30
28
  msgKind: 'response',
31
- frames: [stubHeader(1)],
29
+ frames: [stubHeader()],
32
30
  });
33
31
  /** The 8-byte SSP2 response envelope header (§1.2). */
34
32
  export function responseEnvelopeHeader(wireVersion) {
35
33
  const encoded = encodeMessage({
36
34
  wireVersion,
37
35
  msgKind: 'response',
38
- frames: [stubHeader(wireVersion)],
36
+ frames: [stubHeader()],
39
37
  });
40
38
  return encoded.slice(0, 8);
41
39
  }
42
40
  /** The terminating END frame (§1.2 rule 1). */
43
41
  export const END_FRAME_BYTES = probe.slice(probe.length - 5);
44
- function wrapperFor(frame, wireVersion) {
42
+ function wrapperFor(frame) {
45
43
  switch (frame.type) {
46
44
  case 'RESP_HEADER':
47
45
  return { frames: [frame], index: 0 };
48
46
  case 'LEASE':
49
47
  // §7.3.2: LEASE rides immediately after RESP_HEADER.
50
- return { frames: [stubHeader(wireVersion), frame], index: 1 };
48
+ return { frames: [stubHeader(), frame], index: 1 };
51
49
  case 'PUSH_RESULT':
52
50
  case 'ERROR':
53
51
  case 'UNKNOWN':
54
- return { frames: [stubHeader(wireVersion), frame], index: 1 };
52
+ return { frames: [stubHeader(), frame], index: 1 };
55
53
  case 'PUSH_RESULT_DETAILS': {
56
54
  const result = {
57
55
  type: 'PUSH_RESULT',
@@ -65,23 +63,23 @@ function wrapperFor(frame, wireVersion) {
65
63
  retryable: false,
66
64
  })),
67
65
  };
68
- return { frames: [stubHeader(wireVersion), result, frame], index: 2 };
66
+ return { frames: [stubHeader(), result, frame], index: 2 };
69
67
  }
70
68
  case 'SUB_START':
71
69
  return {
72
- frames: [stubHeader(wireVersion), frame, STUB_SUB_END],
70
+ frames: [stubHeader(), frame, STUB_SUB_END],
73
71
  index: 1,
74
72
  };
75
73
  case 'SUB_END':
76
74
  return {
77
- frames: [stubHeader(wireVersion), STUB_SUB_START, frame],
75
+ frames: [stubHeader(), STUB_SUB_START, frame],
78
76
  index: 2,
79
77
  };
80
78
  case 'COMMIT':
81
79
  case 'SEGMENT_REF':
82
80
  case 'SEGMENT_INLINE':
83
81
  return {
84
- frames: [stubHeader(wireVersion), STUB_SUB_START, frame, STUB_SUB_END],
82
+ frames: [stubHeader(), STUB_SUB_START, frame, STUB_SUB_END],
85
83
  index: 2,
86
84
  };
87
85
  }
@@ -91,7 +89,7 @@ function wrapperFor(frame, wireVersion) {
91
89
  * reference codec.
92
90
  */
93
91
  export function encodeResponseFrame(frame, wireVersion = PROTOCOL_WIRE_VERSION) {
94
- const { frames, index } = wrapperFor(frame, wireVersion);
92
+ const { frames, index } = wrapperFor(frame);
95
93
  const encoded = encodeMessage({
96
94
  wireVersion,
97
95
  msgKind: 'response',
package/dist/handler.js CHANGED
@@ -112,9 +112,6 @@ async function planRequest(request, ctx, schema, registry) {
112
112
  else if (frame.type === 'SUBSCRIPTION')
113
113
  subFrames.push(frame);
114
114
  }
115
- if (request.wireVersion === 1 && registry.epochRequired) {
116
- throw syncError('sync.client_wire_unsupported', 'this partition requires a client that validates log epochs (§2.1)');
117
- }
118
115
  if (request.wireVersion >= 2 &&
119
116
  header.logEpoch === undefined &&
120
117
  pushes.length > 0) {
@@ -1,5 +1,5 @@
1
1
  import { validateAuthoritativeRelationPlan, } from './authoritative-query.js';
2
- import { decodeRow, decodeRemoteOperationRequest, encodeRow, encodeRemoteOperationResponse, } from '@syncular/core';
2
+ import { decodeRow, decodeRemoteOperationRequest, encodeRemoteOperationResponse, encodeSparseRow, } from '@syncular/core';
3
3
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
4
4
  import { SyncError, syncError } from './errors.js';
5
5
  import { processPushOperationsWithTrace } from './push.js';
@@ -270,7 +270,7 @@ function commandOperations(mutations, schema) {
270
270
  table: table.name,
271
271
  rowId,
272
272
  op: 'upsert',
273
- payload: encodeRow(table.columns, values),
273
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
274
274
  };
275
275
  });
276
276
  }
@@ -24,7 +24,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
24
24
  * set, §5.9.5) as an index range, never a scan. `postgres-explain
25
25
  * .test.ts` asserts an `Index` node here too.
26
26
  */
27
- export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
27
+ export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
28
28
  export declare class PostgresServerStorage implements ServerStorage {
29
29
  #private;
30
30
  constructor(exec: PgExecutor);
@@ -108,6 +108,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
108
108
  );
109
109
  CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
110
110
  ON sync_blob_refs(partition, blob_id);
111
+ CREATE TABLE IF NOT EXISTS sync_tombstones(
112
+ partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
113
+ commit_seq BIGINT NOT NULL,
114
+ PRIMARY KEY(partition, tbl, row_id)
115
+ );
111
116
  `;
112
117
  async function lockPartitionOn(client, partition) {
113
118
  const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
@@ -174,6 +179,7 @@ function serializePushResult(result) {
174
179
  message: record.message,
175
180
  serverVersion: record.serverVersion,
176
181
  serverRow: toBase64(record.serverRow),
182
+ conflictColumns: toBase64(record.conflictColumns),
177
183
  };
178
184
  }
179
185
  if (record.status === 'error') {
@@ -201,6 +207,7 @@ function deserializePushResult(value) {
201
207
  message: record.message ?? '',
202
208
  serverVersion: record.serverVersion ?? 0,
203
209
  serverRow: fromBase64(record.serverRow ?? ''),
210
+ conflictColumns: fromBase64(record.conflictColumns ?? ''),
204
211
  };
205
212
  }
206
213
  if (record.status === 'error') {
@@ -242,6 +249,9 @@ function toStoredRow(record) {
242
249
  serverVersion: asNumber(record.server_version),
243
250
  scopes: asJson(record.scopes),
244
251
  payload: asBytes(record.payload),
252
+ ...(record.column_versions === null || record.column_versions === undefined
253
+ ? {}
254
+ : { columnVersions: asBytes(record.column_versions) }),
245
255
  };
246
256
  }
247
257
  function toStoredChange(record) {
@@ -343,6 +353,16 @@ class PostgresTransaction {
343
353
  this.#assertOpen();
344
354
  return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
345
355
  }
356
+ async getTombstoneSeq(table, rowId) {
357
+ this.#assertOpen();
358
+ const { rows } = await this.#client.query('SELECT commit_seq FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
359
+ const seq = rows[0]?.commit_seq;
360
+ return seq === null || seq === undefined ? undefined : asNumber(seq);
361
+ }
362
+ async clearTombstone(table, rowId) {
363
+ this.#assertOpen();
364
+ await this.#client.query('DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
365
+ }
346
366
  getPushResult(clientId, clientCommitId) {
347
367
  this.#assertOpen();
348
368
  // Runs on this transaction's pinned client: the push layer's duplicate
@@ -465,15 +485,23 @@ class PostgresTransaction {
465
485
  continue;
466
486
  // Bind serialized scopes as text before parsing JSONB. Drivers that
467
487
  // encode JSONB parameters would otherwise store this string as a scalar.
488
+ // One statement per change: scopes AND the §5 delete tombstone ride the
489
+ // same writable-CTE chain.
468
490
  await q.query(`WITH inserted AS (
469
491
  INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
470
492
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
471
- RETURNING partition, tbl, commit_seq, scopes
493
+ RETURNING partition, tbl, row_id, op, commit_seq, scopes
494
+ ),
495
+ scoped AS (
496
+ INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
497
+ SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
498
+ FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
499
+ ON CONFLICT DO NOTHING
472
500
  )
473
- INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
474
- SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
475
- FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
476
- ON CONFLICT DO NOTHING`, [
501
+ INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
502
+ SELECT partition, tbl, row_id, commit_seq FROM inserted WHERE op = 2
503
+ ON CONFLICT (partition, tbl, row_id)
504
+ DO UPDATE SET commit_seq = excluded.commit_seq`, [
477
505
  p,
478
506
  commitSeq,
479
507
  idx,
@@ -792,6 +820,8 @@ export class PostgresServerStorage {
792
820
  const removed = await client.query('DELETE FROM sync_commits WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
793
821
  await client.query('DELETE FROM sync_changes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
794
822
  await client.query('DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
823
+ // §4.6: tombstones prune in the same pass as the commit log.
824
+ await client.query('DELETE FROM sync_tombstones WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
795
825
  return {
796
826
  previousHorizonSeq,
797
827
  horizonSeq,