@syncular/server 0.20.0 → 0.21.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.
@@ -18,14 +18,11 @@ import {
18
18
  type SubStartFrame,
19
19
  } from '@syncular/core';
20
20
 
21
- const stubHeader = (wireVersion: number): RespHeaderFrame =>
22
- wireVersion >= 2
23
- ? {
24
- type: 'RESP_HEADER',
25
- logEpoch: 'frame-probe',
26
- resetRequired: false,
27
- }
28
- : { type: 'RESP_HEADER' };
21
+ const stubHeader = (): RespHeaderFrame => ({
22
+ type: 'RESP_HEADER',
23
+ logEpoch: 'frame-probe',
24
+ resetRequired: false,
25
+ });
29
26
  const STUB_SUB_START: SubStartFrame = {
30
27
  type: 'SUB_START',
31
28
  id: '',
@@ -37,9 +34,9 @@ const STUB_SUB_START: SubStartFrame = {
37
34
  const STUB_SUB_END: SubEndFrame = { type: 'SUB_END', nextCursor: 0 };
38
35
 
39
36
  const probe = encodeMessage({
40
- wireVersion: 1,
37
+ wireVersion: PROTOCOL_WIRE_VERSION,
41
38
  msgKind: 'response',
42
- frames: [stubHeader(1)],
39
+ frames: [stubHeader()],
43
40
  });
44
41
 
45
42
  /** The 8-byte SSP2 response envelope header (§1.2). */
@@ -47,7 +44,7 @@ export function responseEnvelopeHeader(wireVersion: number): Uint8Array {
47
44
  const encoded = encodeMessage({
48
45
  wireVersion,
49
46
  msgKind: 'response',
50
- frames: [stubHeader(wireVersion)],
47
+ frames: [stubHeader()],
51
48
  });
52
49
  return encoded.slice(0, 8);
53
50
  }
@@ -55,10 +52,7 @@ export function responseEnvelopeHeader(wireVersion: number): Uint8Array {
55
52
  /** The terminating END frame (§1.2 rule 1). */
56
53
  export const END_FRAME_BYTES: Uint8Array = probe.slice(probe.length - 5);
57
54
 
58
- function wrapperFor(
59
- frame: ResponseFrame,
60
- wireVersion: number,
61
- ): {
55
+ function wrapperFor(frame: ResponseFrame): {
62
56
  frames: ResponseFrame[];
63
57
  index: number;
64
58
  } {
@@ -67,11 +61,11 @@ function wrapperFor(
67
61
  return { frames: [frame], index: 0 };
68
62
  case 'LEASE':
69
63
  // §7.3.2: LEASE rides immediately after RESP_HEADER.
70
- return { frames: [stubHeader(wireVersion), frame], index: 1 };
64
+ return { frames: [stubHeader(), frame], index: 1 };
71
65
  case 'PUSH_RESULT':
72
66
  case 'ERROR':
73
67
  case 'UNKNOWN':
74
- return { frames: [stubHeader(wireVersion), frame], index: 1 };
68
+ return { frames: [stubHeader(), frame], index: 1 };
75
69
  case 'PUSH_RESULT_DETAILS': {
76
70
  const result: PushResultFrame = {
77
71
  type: 'PUSH_RESULT',
@@ -85,23 +79,23 @@ function wrapperFor(
85
79
  retryable: false,
86
80
  })),
87
81
  };
88
- return { frames: [stubHeader(wireVersion), result, frame], index: 2 };
82
+ return { frames: [stubHeader(), result, frame], index: 2 };
89
83
  }
90
84
  case 'SUB_START':
91
85
  return {
92
- frames: [stubHeader(wireVersion), frame, STUB_SUB_END],
86
+ frames: [stubHeader(), frame, STUB_SUB_END],
93
87
  index: 1,
94
88
  };
95
89
  case 'SUB_END':
96
90
  return {
97
- frames: [stubHeader(wireVersion), STUB_SUB_START, frame],
91
+ frames: [stubHeader(), STUB_SUB_START, frame],
98
92
  index: 2,
99
93
  };
100
94
  case 'COMMIT':
101
95
  case 'SEGMENT_REF':
102
96
  case 'SEGMENT_INLINE':
103
97
  return {
104
- frames: [stubHeader(wireVersion), STUB_SUB_START, frame, STUB_SUB_END],
98
+ frames: [stubHeader(), STUB_SUB_START, frame, STUB_SUB_END],
105
99
  index: 2,
106
100
  };
107
101
  }
@@ -115,7 +109,7 @@ export function encodeResponseFrame(
115
109
  frame: ResponseFrame,
116
110
  wireVersion = PROTOCOL_WIRE_VERSION,
117
111
  ): Uint8Array {
118
- const { frames, index } = wrapperFor(frame, wireVersion);
112
+ const { frames, index } = wrapperFor(frame);
119
113
  const encoded = encodeMessage({
120
114
  wireVersion,
121
115
  msgKind: 'response',
package/src/handler.ts CHANGED
@@ -211,12 +211,6 @@ async function planRequest(
211
211
  else if (frame.type === 'SUBSCRIPTION') subFrames.push(frame);
212
212
  }
213
213
 
214
- if (request.wireVersion === 1 && registry.epochRequired) {
215
- throw syncError(
216
- 'sync.client_wire_unsupported',
217
- 'this partition requires a client that validates log epochs (§2.1)',
218
- );
219
- }
220
214
  if (
221
215
  request.wireVersion >= 2 &&
222
216
  header.logEpoch === undefined &&
package/src/operations.ts CHANGED
@@ -5,8 +5,8 @@ import {
5
5
  import {
6
6
  decodeRow,
7
7
  decodeRemoteOperationRequest,
8
- encodeRow,
9
8
  encodeRemoteOperationResponse,
9
+ encodeSparseRow,
10
10
  type PushOperation,
11
11
  type RemoteOperationResponse,
12
12
  type RowValue,
@@ -526,7 +526,7 @@ function commandOperations(
526
526
  table: table.name,
527
527
  rowId,
528
528
  op: 'upsert',
529
- payload: encodeRow(table.columns, values),
529
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
530
530
  };
531
531
  });
532
532
  }
@@ -213,6 +213,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
213
213
  );
214
214
  CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
215
215
  ON sync_blob_refs(partition, blob_id);
216
+ CREATE TABLE IF NOT EXISTS sync_tombstones(
217
+ partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
218
+ commit_seq BIGINT NOT NULL,
219
+ PRIMARY KEY(partition, tbl, row_id)
220
+ );
216
221
  `;
217
222
 
218
223
  interface SerializedResult {
@@ -222,6 +227,7 @@ interface SerializedResult {
222
227
  message?: string;
223
228
  serverVersion?: number;
224
229
  serverRow?: string;
230
+ conflictColumns?: string;
225
231
  retryable?: boolean;
226
232
  details?: import('@syncular/core').RejectionDetails;
227
233
  }
@@ -325,6 +331,7 @@ function serializePushResult(result: StoredPushResult): unknown {
325
331
  message: record.message,
326
332
  serverVersion: record.serverVersion,
327
333
  serverRow: toBase64(record.serverRow),
334
+ conflictColumns: toBase64(record.conflictColumns),
328
335
  };
329
336
  }
330
337
  if (record.status === 'error') {
@@ -359,6 +366,7 @@ function deserializePushResult(value: unknown): StoredPushResult {
359
366
  message: record.message ?? '',
360
367
  serverVersion: record.serverVersion ?? 0,
361
368
  serverRow: fromBase64(record.serverRow ?? ''),
369
+ conflictColumns: fromBase64(record.conflictColumns ?? ''),
362
370
  };
363
371
  }
364
372
  if (record.status === 'error') {
@@ -400,6 +408,7 @@ interface RowRecord {
400
408
  server_version: unknown;
401
409
  scopes: unknown;
402
410
  payload: unknown;
411
+ column_versions: unknown;
403
412
  }
404
413
 
405
414
  interface ChangeRecord {
@@ -434,6 +443,9 @@ function toStoredRow(record: RowRecord): StoredRow {
434
443
  serverVersion: asNumber(record.server_version),
435
444
  scopes: asJson<Record<string, string>>(record.scopes),
436
445
  payload: asBytes(record.payload),
446
+ ...(record.column_versions === null || record.column_versions === undefined
447
+ ? {}
448
+ : { columnVersions: asBytes(record.column_versions) }),
437
449
  };
438
450
  }
439
451
 
@@ -610,6 +622,27 @@ class PostgresTransaction implements StorageTransaction {
610
622
  );
611
623
  }
612
624
 
625
+ async getTombstoneSeq(
626
+ table: string,
627
+ rowId: string,
628
+ ): Promise<number | undefined> {
629
+ this.#assertOpen();
630
+ const { rows } = await this.#client.query<{ commit_seq: unknown }>(
631
+ 'SELECT commit_seq FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3',
632
+ [this.#partition, table, rowId],
633
+ );
634
+ const seq = rows[0]?.commit_seq;
635
+ return seq === null || seq === undefined ? undefined : asNumber(seq);
636
+ }
637
+
638
+ async clearTombstone(table: string, rowId: string): Promise<void> {
639
+ this.#assertOpen();
640
+ await this.#client.query(
641
+ 'DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3',
642
+ [this.#partition, table, rowId],
643
+ );
644
+ }
645
+
613
646
  getPushResult(
614
647
  clientId: string,
615
648
  clientCommitId: string,
@@ -784,16 +817,24 @@ class PostgresTransaction implements StorageTransaction {
784
817
  if (change === undefined) continue;
785
818
  // Bind serialized scopes as text before parsing JSONB. Drivers that
786
819
  // encode JSONB parameters would otherwise store this string as a scalar.
820
+ // One statement per change: scopes AND the §5 delete tombstone ride the
821
+ // same writable-CTE chain.
787
822
  await q.query(
788
823
  `WITH inserted AS (
789
824
  INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
790
825
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
791
- RETURNING partition, tbl, commit_seq, scopes
826
+ RETURNING partition, tbl, row_id, op, commit_seq, scopes
827
+ ),
828
+ scoped AS (
829
+ INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
830
+ SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
831
+ FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
832
+ ON CONFLICT DO NOTHING
792
833
  )
793
- INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
794
- SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
795
- FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
796
- ON CONFLICT DO NOTHING`,
834
+ INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
835
+ SELECT partition, tbl, row_id, commit_seq FROM inserted WHERE op = 2
836
+ ON CONFLICT (partition, tbl, row_id)
837
+ DO UPDATE SET commit_seq = excluded.commit_seq`,
797
838
  [
798
839
  p,
799
840
  commitSeq,
@@ -1258,6 +1299,11 @@ export class PostgresServerStorage implements ServerStorage {
1258
1299
  'DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2',
1259
1300
  [partition, horizonSeq],
1260
1301
  );
1302
+ // §4.6: tombstones prune in the same pass as the commit log.
1303
+ await client.query(
1304
+ 'DELETE FROM sync_tombstones WHERE partition=$1 AND commit_seq<=$2',
1305
+ [partition, horizonSeq],
1306
+ );
1261
1307
  return {
1262
1308
  previousHorizonSeq,
1263
1309
  horizonSeq,