@syncular/core 0.59.0 → 0.60.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.
@@ -1,10 +1,21 @@
1
- /**
2
- * Canonical JSON for digests (SPEC.md §11.2) — contractual across
3
- * implementations: UTF-8, no insignificant whitespace, object keys sorted
4
- * by code-unit, scope value lists sorted and deduplicated.
5
- *
6
- * Used for the scope digest (§3.5) and the `X-Syncular-Scopes` header
7
- * (§5.5). Hashing itself is the host's job; this module only produces the
8
- * canonical text.
9
- */
1
+ import type { FencedRecord } from './message.js';
2
+ /** A unit's scope map: one value per requested variable (§4.1). */
3
+ export type UnitScopes = Readonly<Record<string, string>>;
10
4
  export declare function canonicalScopeJson(scopes: Readonly<Record<string, readonly string[]>>): string;
5
+ /** §4.1: the canonical JSON of a unit's scope map. */
6
+ export declare function canonicalUnitJson(scopes: UnitScopes): string;
7
+ /** §4.1: lowercase hex of the first 16 bytes of SHA-256 over the canonical
8
+ * JSON of the unit's scope map. */
9
+ export declare function unitId(scopes: UnitScopes): string;
10
+ /** §4.1: SHA-256 over the canonical JSON array of the units' scope maps in
11
+ * registration order, as 32 raw bytes. */
12
+ export declare function registrationDigest(units: readonly UnitScopes[]): Uint8Array;
13
+ /** One unit of the effective list as the echo digest sees it (§4.4). */
14
+ export interface UnitEcho {
15
+ id: string;
16
+ status: 'active' | 'revoked';
17
+ fenced: readonly FencedRecord[];
18
+ }
19
+ /** §4.4: SHA-256 over the canonical JSON array, in registration order, of
20
+ * `{"fenced":[…],"id":…,"status":…}` per unit, as 32 raw bytes. */
21
+ export declare function echoDigest(units: readonly UnitEcho[]): Uint8Array;
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Canonical JSON for digests (SPEC.md §11.2) — contractual across
3
- * implementations: UTF-8, no insignificant whitespace, object keys sorted
4
- * by code-unit, scope value lists sorted and deduplicated.
2
+ * Canonical JSON for digests (SPEC.md §11.2) and the digests over it —
3
+ * contractual across implementations: UTF-8, no insignificant whitespace,
4
+ * object keys sorted by code-unit, scope value lists sorted and
5
+ * deduplicated, every other array in its stated order.
5
6
  *
6
- * Used for the scope digest (§3.5) and the `X-Syncular-Scopes` header
7
- * (§5.5). Hashing itself is the host's job; this module only produces the
8
- * canonical text.
7
+ * Used for the unit id and registration digest (§4.1), the echo digest
8
+ * (§4.4), the scope digest (§3.5) and the `X-Syncular-Scopes` header (§5.5).
9
9
  */
10
+ import { utf8Encode } from './bytes.js';
10
11
  export function canonicalScopeJson(scopes) {
11
12
  const keys = Object.keys(scopes).sort();
12
13
  const parts = keys.map((key) => {
@@ -16,3 +17,104 @@ export function canonicalScopeJson(scopes) {
16
17
  });
17
18
  return `{${parts.join(',')}}`;
18
19
  }
20
+ /** §4.1: the canonical JSON of a unit's scope map. */
21
+ export function canonicalUnitJson(scopes) {
22
+ const keys = Object.keys(scopes).sort();
23
+ const parts = keys.map((key) => `${JSON.stringify(key)}:${JSON.stringify(scopes[key])}`);
24
+ return `{${parts.join(',')}}`;
25
+ }
26
+ /** §4.1: lowercase hex of the first 16 bytes of SHA-256 over the canonical
27
+ * JSON of the unit's scope map. */
28
+ export function unitId(scopes) {
29
+ let hex = '';
30
+ for (const byte of sha256(utf8Encode(canonicalUnitJson(scopes))).subarray(0, 16)) {
31
+ hex += byte.toString(16).padStart(2, '0');
32
+ }
33
+ return hex;
34
+ }
35
+ /** §4.1: SHA-256 over the canonical JSON array of the units' scope maps in
36
+ * registration order, as 32 raw bytes. */
37
+ export function registrationDigest(units) {
38
+ return sha256(utf8Encode(`[${units.map(canonicalUnitJson).join(',')}]`));
39
+ }
40
+ /** §4.4: SHA-256 over the canonical JSON array, in registration order, of
41
+ * `{"fenced":[…],"id":…,"status":…}` per unit, as 32 raw bytes. */
42
+ export function echoDigest(units) {
43
+ const entries = units.map((unit) => {
44
+ const fenced = unit.fenced.map((record) => `{"scopes":${canonicalScopeJson(record.scopes)},"status":${JSON.stringify(record.status)},"table":${JSON.stringify(record.table)}}`);
45
+ return `{"fenced":[${fenced.join(',')}],"id":${JSON.stringify(unit.id)},"status":${JSON.stringify(unit.status)}}`;
46
+ });
47
+ return sha256(utf8Encode(`[${entries.join(',')}]`));
48
+ }
49
+ // FIPS 180-4 SHA-256. Synchronous so the unit id is a plain function of the
50
+ // scope map on every runtime (WebCrypto digests are async only).
51
+ const K = new Uint32Array([
52
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
53
+ 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
54
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
55
+ 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
56
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
57
+ 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
58
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
59
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
60
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
61
+ 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
62
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
63
+ ]);
64
+ function sha256(data) {
65
+ const padded = new Uint8Array(((data.length + 72) >>> 6) << 6);
66
+ padded.set(data);
67
+ padded[data.length] = 0x80;
68
+ const view = new DataView(padded.buffer);
69
+ view.setUint32(padded.length - 8, Math.floor(data.length / 0x20000000));
70
+ view.setUint32(padded.length - 4, (data.length * 8) >>> 0);
71
+ const h = [
72
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
73
+ 0x1f83d9ab, 0x5be0cd19,
74
+ ];
75
+ const w = new Uint32Array(64);
76
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
77
+ for (let offset = 0; offset < padded.length; offset += 64) {
78
+ for (let i = 0; i < 16; i++)
79
+ w[i] = view.getUint32(offset + i * 4);
80
+ for (let i = 16; i < 64; i++) {
81
+ const a = w[i - 15];
82
+ const b = w[i - 2];
83
+ w[i] =
84
+ (rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10)) +
85
+ w[i - 7] +
86
+ (rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3)) +
87
+ w[i - 16];
88
+ }
89
+ let [a, b, c, d, e, f, g, hh] = h;
90
+ for (let i = 0; i < 64; i++) {
91
+ const t1 = hh +
92
+ (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) +
93
+ ((e & f) ^ (~e & g)) +
94
+ K[i] +
95
+ w[i];
96
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) +
97
+ ((a & b) ^ (a & c) ^ (b & c));
98
+ hh = g;
99
+ g = f;
100
+ f = e;
101
+ e = (d + t1) | 0;
102
+ d = c;
103
+ c = b;
104
+ b = a;
105
+ a = (t1 + t2) | 0;
106
+ }
107
+ h[0] = (h[0] + a) | 0;
108
+ h[1] = (h[1] + b) | 0;
109
+ h[2] = (h[2] + c) | 0;
110
+ h[3] = (h[3] + d) | 0;
111
+ h[4] = (h[4] + e) | 0;
112
+ h[5] = (h[5] + f) | 0;
113
+ h[6] = (h[6] + g) | 0;
114
+ h[7] = (h[7] + hh) | 0;
115
+ }
116
+ const out = new Uint8Array(32);
117
+ const outView = new DataView(out.buffer);
118
+ h.forEach((word, i) => outView.setUint32(i * 4, word));
119
+ return out;
120
+ }
@@ -1,10 +1,10 @@
1
1
  /** Protocol constants (SPEC.md §1.2, §9 are normative). */
2
2
  /** Latest SSP2 envelope wire version emitted by current clients. */
3
- export declare const PROTOCOL_WIRE_VERSION = 3;
4
- /** Oldest SSP2 envelope wire version accepted. Wire version 3 changed the
5
- * push payload to the sparse row codec (§2.4) and the conflict record shape
6
- * (§6.3); versions 1 and 2 are rejected as unknown (§9). */
7
- export declare const MINIMUM_PROTOCOL_WIRE_VERSION = 3;
3
+ export declare const PROTOCOL_WIRE_VERSION = 4;
4
+ /** Oldest SSP2 envelope wire version accepted. Wire version 4 replaced
5
+ * per-table subscriptions with units and the replica cursor (§4); earlier
6
+ * versions are rejected as unknown (§9). */
7
+ export declare const MINIMUM_PROTOCOL_WIRE_VERSION = 4;
8
8
  /** Wire versions implemented by the reference codec, oldest first. */
9
9
  export declare const SUPPORTED_PROTOCOL_WIRE_VERSIONS: readonly number[];
10
10
  /** Whether the reference codec implements this exact wire version. */
package/dist/constants.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /** Protocol constants (SPEC.md §1.2, §9 are normative). */
2
2
  /** Latest SSP2 envelope wire version emitted by current clients. */
3
- export const PROTOCOL_WIRE_VERSION = 3;
4
- /** Oldest SSP2 envelope wire version accepted. Wire version 3 changed the
5
- * push payload to the sparse row codec (§2.4) and the conflict record shape
6
- * (§6.3); versions 1 and 2 are rejected as unknown (§9). */
7
- export const MINIMUM_PROTOCOL_WIRE_VERSION = 3;
3
+ export const PROTOCOL_WIRE_VERSION = 4;
4
+ /** Oldest SSP2 envelope wire version accepted. Wire version 4 replaced
5
+ * per-table subscriptions with units and the replica cursor (§4); earlier
6
+ * versions are rejected as unknown (§9). */
7
+ export const MINIMUM_PROTOCOL_WIRE_VERSION = 4;
8
8
  /** Wire versions implemented by the reference codec, oldest first. */
9
9
  export const SUPPORTED_PROTOCOL_WIRE_VERSIONS = Object.freeze(Array.from({
10
10
  length: PROTOCOL_WIRE_VERSION - MINIMUM_PROTOCOL_WIRE_VERSION + 1,
package/dist/frames.d.ts CHANGED
@@ -1,11 +1,12 @@
1
- /** Frame type registry for wire versions 1 and 2 (SPEC.md §1.2). */
1
+ /** Frame type registry for wire version 4 (SPEC.md §1.2). */
2
2
  export declare const FrameType: {
3
3
  readonly END: 0;
4
4
  readonly REQ_HEADER: 1;
5
5
  readonly PUSH_COMMIT: 2;
6
6
  readonly PULL_HEADER: 3;
7
- readonly SUBSCRIPTION: 4;
7
+ readonly UNIT: 4;
8
8
  readonly ROUND_FAILURE: 5;
9
+ readonly PROGRESS: 6;
9
10
  readonly RESP_HEADER: 16;
10
11
  readonly PUSH_RESULT: 17;
11
12
  readonly SUB_START: 18;
@@ -15,6 +16,8 @@ export declare const FrameType: {
15
16
  readonly SUB_END: 22;
16
17
  readonly LEASE: 25;
17
18
  readonly PUSH_RESULT_DETAILS: 27;
19
+ readonly LIVE_START: 28;
20
+ readonly LIVE_END: 29;
18
21
  readonly ERROR: 31;
19
22
  };
20
23
  export type FrameTypeName = keyof typeof FrameType;
@@ -22,5 +25,5 @@ export type FrameTypeName = keyof typeof FrameType;
22
25
  export declare const REQUEST_FRAME_TYPES: ReadonlySet<number>;
23
26
  /** Known response frame types (excluding END). */
24
27
  export declare const RESPONSE_FRAME_TYPES: ReadonlySet<number>;
25
- /** Every frame type with a defined layout in wire versions 1 and 2. */
28
+ /** Every frame type with a defined layout in wire version 4. */
26
29
  export declare const KNOWN_FRAME_TYPES: ReadonlySet<number>;
package/dist/frames.js CHANGED
@@ -1,11 +1,12 @@
1
- /** Frame type registry for wire versions 1 and 2 (SPEC.md §1.2). */
1
+ /** Frame type registry for wire version 4 (SPEC.md §1.2). */
2
2
  export const FrameType = {
3
3
  END: 0x00,
4
4
  REQ_HEADER: 0x01,
5
5
  PUSH_COMMIT: 0x02,
6
6
  PULL_HEADER: 0x03,
7
- SUBSCRIPTION: 0x04,
7
+ UNIT: 0x04,
8
8
  ROUND_FAILURE: 0x05,
9
+ PROGRESS: 0x06,
9
10
  RESP_HEADER: 0x10,
10
11
  PUSH_RESULT: 0x11,
11
12
  SUB_START: 0x12,
@@ -15,6 +16,8 @@ export const FrameType = {
15
16
  SUB_END: 0x16,
16
17
  LEASE: 0x19,
17
18
  PUSH_RESULT_DETAILS: 0x1b,
19
+ LIVE_START: 0x1c,
20
+ LIVE_END: 0x1d,
18
21
  ERROR: 0x1f,
19
22
  };
20
23
  /** Known request frame types (excluding END). */
@@ -22,8 +25,9 @@ export const REQUEST_FRAME_TYPES = new Set([
22
25
  FrameType.REQ_HEADER,
23
26
  FrameType.PUSH_COMMIT,
24
27
  FrameType.PULL_HEADER,
25
- FrameType.SUBSCRIPTION,
28
+ FrameType.UNIT,
26
29
  FrameType.ROUND_FAILURE,
30
+ FrameType.PROGRESS,
27
31
  ]);
28
32
  /** Known response frame types (excluding END). */
29
33
  export const RESPONSE_FRAME_TYPES = new Set([
@@ -36,9 +40,11 @@ export const RESPONSE_FRAME_TYPES = new Set([
36
40
  FrameType.SUB_END,
37
41
  FrameType.LEASE,
38
42
  FrameType.PUSH_RESULT_DETAILS,
43
+ FrameType.LIVE_START,
44
+ FrameType.LIVE_END,
39
45
  FrameType.ERROR,
40
46
  ]);
41
- /** Every frame type with a defined layout in wire versions 1 and 2. */
47
+ /** Every frame type with a defined layout in wire version 4. */
42
48
  export const KNOWN_FRAME_TYPES = new Set([
43
49
  FrameType.END,
44
50
  ...REQUEST_FRAME_TYPES,
package/dist/message.d.ts CHANGED
@@ -6,6 +6,9 @@ export interface ReqHeaderFrame {
6
6
  schemaVersion: number;
7
7
  /** Partition log identity; absent requests epoch acquisition. */
8
8
  logEpoch?: string;
9
+ /** Identity of the sender's outbox sequence (§6.3.2, §7.1); absent for a
10
+ * sender without an outbox. Non-empty, at most 64 bytes. */
11
+ outboxId?: string;
9
12
  }
10
13
  export interface PushOperation {
11
14
  table: string;
@@ -19,6 +22,8 @@ export interface PushOperation {
19
22
  export interface PushCommitFrame {
20
23
  type: 'PUSH_COMMIT';
21
24
  clientCommitId: string;
25
+ /** Outbox sequence number (§6.3.2, §7.1); `0` without an outbox. */
26
+ clientSeq: number;
22
27
  operations: PushOperation[];
23
28
  }
24
29
  export interface PullHeaderFrame {
@@ -28,14 +33,24 @@ export interface PullHeaderFrame {
28
33
  maxSnapshotPages: number;
29
34
  /** Bitmask (§4.2): bit 0 inline rows, 1 external rows, 2 sqlite, 3 signed URLs. */
30
35
  accept: number;
36
+ /** The replica cursor (§4.5); `-1` when no unit is live. */
37
+ cursor: number;
38
+ /** §4.1 registration digest: exactly 32 bytes. */
39
+ registrationDigest: Uint8Array;
40
+ /** The last processed `LIVE_END.echoDigest`: empty or 32 bytes (§4.2). */
41
+ echoDigest: Uint8Array;
42
+ }
43
+ /** §4.3: one unit of a traveling registration list. */
44
+ export interface UnitFrame {
45
+ type: 'UNIT';
46
+ scopes: Record<string, string>;
31
47
  }
32
- export interface SubscriptionFrame {
33
- type: 'SUBSCRIPTION';
48
+ /** §4.3: the progress of one catching-up unit. */
49
+ export interface ProgressFrame {
50
+ type: 'PROGRESS';
51
+ /** The unit id (§4.1): 32 lowercase hex characters. */
34
52
  id: string;
35
- table: string;
36
- scopes: ScopeMap;
37
- /** Host-opaque JSON document, preserved verbatim. */
38
- params?: string;
53
+ /** `-1`, the §4.7 pin while a resume token is held, or the unit cursor. */
39
54
  cursor: number;
40
55
  /** Opaque resume token JSON, round-tripped byte-for-byte (§4.7). */
41
56
  bootstrapState?: string;
@@ -53,7 +68,7 @@ export interface RoundFailureFrame {
53
68
  type: 'ROUND_FAILURE';
54
69
  code: string;
55
70
  }
56
- export type RequestFrame = ReqHeaderFrame | RoundFailureFrame | PushCommitFrame | PullHeaderFrame | SubscriptionFrame | UnknownFrame;
71
+ export type RequestFrame = ReqHeaderFrame | RoundFailureFrame | PushCommitFrame | PullHeaderFrame | UnitFrame | ProgressFrame | UnknownFrame;
57
72
  export interface RespHeaderFrame {
58
73
  type: 'RESP_HEADER';
59
74
  requiredSchemaVersion?: number;
@@ -118,20 +133,43 @@ export interface PushResultDetailsFrame {
118
133
  details: RejectionDetails;
119
134
  }>;
120
135
  }
136
+ /** §4.4: one (unit, table) pair the wildcard fence touched. */
137
+ export interface FencedRecord {
138
+ table: string;
139
+ status: 'active' | 'revoked';
140
+ /** Effective lists of the variables the fence added; empty when revoked. */
141
+ scopes: ScopeMap;
142
+ }
121
143
  export interface SubStartFrame {
122
144
  type: 'SUB_START';
145
+ /** The unit id (§4.1): 32 lowercase hex characters. */
123
146
  id: string;
124
147
  status: 'active' | 'revoked' | 'reset';
125
148
  reasonCode: string;
126
- effectiveScopes: ScopeMap;
127
149
  bootstrap: boolean;
150
+ /** The unit's echo, in ascending table order (§4.4). */
151
+ fenced: FencedRecord[];
152
+ }
153
+ export interface LiveStartFrame {
154
+ type: 'LIVE_START';
155
+ status: 'active' | 'reset' | 'unregistered';
156
+ reasonCode: string;
157
+ }
158
+ export interface LiveEndFrame {
159
+ type: 'LIVE_END';
160
+ /** Highest commitSeq the live section is complete through; `-1` for
161
+ * `reset` and `unregistered` (§4.4). */
162
+ throughSeq: number;
163
+ /** Echo digest of the request: empty or 32 bytes (§4.4). */
164
+ echoDigest: Uint8Array;
128
165
  }
129
166
  export interface CommitChange {
130
167
  tableIndex: number;
131
168
  rowId: string;
132
169
  op: 'upsert' | 'delete';
133
- /** Present iff `op` is `upsert` (§4.5). */
134
- rowVersion?: number;
170
+ /** The row's `server_version` after the write, ≥ 1; a delete's is the
171
+ * version its tombstone records (§2.2, §4.5). */
172
+ rowVersion: number;
135
173
  scopes: Record<string, string>;
136
174
  /** Generated-row-codec bytes; present iff `op` is `upsert` (§4.5). */
137
175
  row?: Uint8Array;
@@ -167,7 +205,7 @@ export interface SegmentInlineFrame {
167
205
  export interface SubEndFrame {
168
206
  type: 'SUB_END';
169
207
  nextCursor: number;
170
- /** Present iff the bootstrap is incomplete (§4.4). */
208
+ /** Present iff the unit's bootstrap is incomplete (§4.4). */
171
209
  bootstrapState?: string;
172
210
  }
173
211
  export interface ErrorFrame {
@@ -179,7 +217,7 @@ export interface ErrorFrame {
179
217
  recommendedAction: string;
180
218
  details?: string;
181
219
  }
182
- export type ResponseFrame = RespHeaderFrame | LeaseFrame | PushResultFrame | PushResultDetailsFrame | SubStartFrame | CommitFrame | SegmentRefFrame | SegmentInlineFrame | SubEndFrame | ErrorFrame | UnknownFrame;
220
+ export type ResponseFrame = RespHeaderFrame | LeaseFrame | PushResultFrame | PushResultDetailsFrame | SubStartFrame | CommitFrame | SegmentRefFrame | SegmentInlineFrame | SubEndFrame | LiveStartFrame | LiveEndFrame | ErrorFrame | UnknownFrame;
183
221
  export interface RequestMessage {
184
222
  wireVersion: number;
185
223
  msgKind: 'request';
@@ -193,6 +231,9 @@ export interface ResponseMessage {
193
231
  export type SyncMessage = RequestMessage | ResponseMessage;
194
232
  /** §1.5: `code` is a legal `ROUND_FAILURE` code (grammar and byte length). */
195
233
  export declare function isRoundFailureCode(code: string): boolean;
196
- export declare function validateFrameSequence(message: SyncMessage): void;
234
+ export declare function encodeRequest(message: RequestMessage): Uint8Array;
235
+ export declare function encodeResponse(message: ResponseMessage): Uint8Array;
197
236
  export declare function encodeMessage(message: SyncMessage): Uint8Array;
237
+ export declare function decodeRequest(bytes: Uint8Array): RequestMessage;
238
+ export declare function decodeResponse(bytes: Uint8Array): ResponseMessage;
198
239
  export declare function decodeMessage(bytes: Uint8Array): SyncMessage;