@syncular/core 0.59.0 → 0.60.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.
package/dist/render.js CHANGED
@@ -42,6 +42,13 @@ function renderScopeLists(scopes) {
42
42
  function renderStringMap(map) {
43
43
  return { ...map };
44
44
  }
45
+ /** Digest fields render as lowercase hex (Conventions: hashes are hex). */
46
+ function renderDigest(digest) {
47
+ let hex = '';
48
+ for (const byte of digest)
49
+ hex += byte.toString(16).padStart(2, '0');
50
+ return hex;
51
+ }
45
52
  function renderOperation(operation) {
46
53
  return {
47
54
  table: operation.table,
@@ -83,9 +90,7 @@ function renderChange(change) {
83
90
  tableIndex: change.tableIndex,
84
91
  rowId: change.rowId,
85
92
  op: change.op,
86
- ...(change.rowVersion !== undefined
87
- ? { rowVersion: change.rowVersion }
88
- : {}),
93
+ rowVersion: change.rowVersion,
89
94
  scopes: renderStringMap(change.scopes),
90
95
  ...(change.row !== undefined ? { row: bytesToBase64(change.row) } : {}),
91
96
  };
@@ -98,6 +103,7 @@ function renderFrame(frame) {
98
103
  clientId: frame.clientId,
99
104
  schemaVersion: frame.schemaVersion,
100
105
  ...(frame.logEpoch !== undefined ? { logEpoch: frame.logEpoch } : {}),
106
+ ...(frame.outboxId !== undefined ? { outboxId: frame.outboxId } : {}),
101
107
  };
102
108
  case 'ROUND_FAILURE':
103
109
  return { type: 'ROUND_FAILURE', code: frame.code };
@@ -105,6 +111,7 @@ function renderFrame(frame) {
105
111
  return {
106
112
  type: 'PUSH_COMMIT',
107
113
  clientCommitId: frame.clientCommitId,
114
+ clientSeq: frame.clientSeq,
108
115
  operations: frame.operations.map(renderOperation),
109
116
  };
110
117
  case 'PULL_HEADER':
@@ -114,16 +121,16 @@ function renderFrame(frame) {
114
121
  limitSnapshotRows: frame.limitSnapshotRows,
115
122
  maxSnapshotPages: frame.maxSnapshotPages,
116
123
  accept: frame.accept,
124
+ cursor: frame.cursor,
125
+ registrationDigest: renderDigest(frame.registrationDigest),
126
+ echoDigest: renderDigest(frame.echoDigest),
117
127
  };
118
- case 'SUBSCRIPTION':
128
+ case 'UNIT':
129
+ return { type: 'UNIT', scopes: renderStringMap(frame.scopes) };
130
+ case 'PROGRESS':
119
131
  return {
120
- type: 'SUBSCRIPTION',
132
+ type: 'PROGRESS',
121
133
  id: frame.id,
122
- table: frame.table,
123
- scopes: renderScopeLists(frame.scopes),
124
- ...(frame.params !== undefined
125
- ? { params: parseJson(frame.params) }
126
- : {}),
127
134
  cursor: frame.cursor,
128
135
  ...(frame.bootstrapState !== undefined
129
136
  ? { bootstrapState: parseJson(frame.bootstrapState) }
@@ -187,8 +194,24 @@ function renderFrame(frame) {
187
194
  id: frame.id,
188
195
  status: frame.status,
189
196
  reasonCode: frame.reasonCode,
190
- effectiveScopes: renderScopeLists(frame.effectiveScopes),
191
197
  bootstrap: frame.bootstrap,
198
+ fenced: frame.fenced.map((record) => ({
199
+ table: record.table,
200
+ status: record.status,
201
+ scopes: renderScopeLists(record.scopes),
202
+ })),
203
+ };
204
+ case 'LIVE_START':
205
+ return {
206
+ type: 'LIVE_START',
207
+ status: frame.status,
208
+ reasonCode: frame.reasonCode,
209
+ };
210
+ case 'LIVE_END':
211
+ return {
212
+ type: 'LIVE_END',
213
+ throughSeq: frame.throughSeq,
214
+ echoDigest: renderDigest(frame.echoDigest),
192
215
  };
193
216
  case 'COMMIT':
194
217
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/core",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Core protocol types and shared utilities for the Syncular sync framework",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -1,12 +1,18 @@
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';
11
+ import type { FencedRecord } from './message';
12
+
13
+ /** A unit's scope map: one value per requested variable (§4.1). */
14
+ export type UnitScopes = Readonly<Record<string, string>>;
15
+
10
16
  export function canonicalScopeJson(
11
17
  scopes: Readonly<Record<string, readonly string[]>>,
12
18
  ): string {
@@ -18,3 +24,126 @@ export function canonicalScopeJson(
18
24
  });
19
25
  return `{${parts.join(',')}}`;
20
26
  }
27
+
28
+ /** §4.1: the canonical JSON of a unit's scope map. */
29
+ export function canonicalUnitJson(scopes: UnitScopes): string {
30
+ const keys = Object.keys(scopes).sort();
31
+ const parts = keys.map(
32
+ (key) => `${JSON.stringify(key)}:${JSON.stringify(scopes[key])}`,
33
+ );
34
+ return `{${parts.join(',')}}`;
35
+ }
36
+
37
+ /** §4.1: lowercase hex of the first 16 bytes of SHA-256 over the canonical
38
+ * JSON of the unit's scope map. */
39
+ export function unitId(scopes: UnitScopes): string {
40
+ let hex = '';
41
+ for (const byte of sha256(utf8Encode(canonicalUnitJson(scopes))).subarray(
42
+ 0,
43
+ 16,
44
+ )) {
45
+ hex += byte.toString(16).padStart(2, '0');
46
+ }
47
+ return hex;
48
+ }
49
+
50
+ /** §4.1: SHA-256 over the canonical JSON array of the units' scope maps in
51
+ * registration order, as 32 raw bytes. */
52
+ export function registrationDigest(units: readonly UnitScopes[]): Uint8Array {
53
+ return sha256(utf8Encode(`[${units.map(canonicalUnitJson).join(',')}]`));
54
+ }
55
+
56
+ /** One unit of the effective list as the echo digest sees it (§4.4). */
57
+ export interface UnitEcho {
58
+ id: string;
59
+ status: 'active' | 'revoked';
60
+ fenced: readonly FencedRecord[];
61
+ }
62
+
63
+ /** §4.4: SHA-256 over the canonical JSON array, in registration order, of
64
+ * `{"fenced":[…],"id":…,"status":…}` per unit, as 32 raw bytes. */
65
+ export function echoDigest(units: readonly UnitEcho[]): Uint8Array {
66
+ const entries = units.map((unit) => {
67
+ const fenced = unit.fenced.map(
68
+ (record) =>
69
+ `{"scopes":${canonicalScopeJson(record.scopes)},"status":${JSON.stringify(record.status)},"table":${JSON.stringify(record.table)}}`,
70
+ );
71
+ return `{"fenced":[${fenced.join(',')}],"id":${JSON.stringify(unit.id)},"status":${JSON.stringify(unit.status)}}`;
72
+ });
73
+ return sha256(utf8Encode(`[${entries.join(',')}]`));
74
+ }
75
+
76
+ // FIPS 180-4 SHA-256. Synchronous so the unit id is a plain function of the
77
+ // scope map on every runtime (WebCrypto digests are async only).
78
+ const K = new Uint32Array([
79
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
80
+ 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
81
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
82
+ 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
83
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
84
+ 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
85
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
86
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
87
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
88
+ 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
89
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
90
+ ]);
91
+
92
+ function sha256(data: Uint8Array): Uint8Array {
93
+ const padded = new Uint8Array(((data.length + 72) >>> 6) << 6);
94
+ padded.set(data);
95
+ padded[data.length] = 0x80;
96
+ const view = new DataView(padded.buffer);
97
+ view.setUint32(padded.length - 8, Math.floor(data.length / 0x20000000));
98
+ view.setUint32(padded.length - 4, (data.length * 8) >>> 0);
99
+ const h: [number, number, number, number, number, number, number, number] = [
100
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
101
+ 0x1f83d9ab, 0x5be0cd19,
102
+ ];
103
+ const w = new Uint32Array(64);
104
+ const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
105
+ for (let offset = 0; offset < padded.length; offset += 64) {
106
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
107
+ for (let i = 16; i < 64; i++) {
108
+ const a = w[i - 15] as number;
109
+ const b = w[i - 2] as number;
110
+ w[i] =
111
+ (rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10)) +
112
+ (w[i - 7] as number) +
113
+ (rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3)) +
114
+ (w[i - 16] as number);
115
+ }
116
+ let [a, b, c, d, e, f, g, hh] = h;
117
+ for (let i = 0; i < 64; i++) {
118
+ const t1 =
119
+ hh +
120
+ (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) +
121
+ ((e & f) ^ (~e & g)) +
122
+ (K[i] as number) +
123
+ (w[i] as number);
124
+ const t2 =
125
+ (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) +
126
+ ((a & b) ^ (a & c) ^ (b & c));
127
+ hh = g;
128
+ g = f;
129
+ f = e;
130
+ e = (d + t1) | 0;
131
+ d = c;
132
+ c = b;
133
+ b = a;
134
+ a = (t1 + t2) | 0;
135
+ }
136
+ h[0] = (h[0] + a) | 0;
137
+ h[1] = (h[1] + b) | 0;
138
+ h[2] = (h[2] + c) | 0;
139
+ h[3] = (h[3] + d) | 0;
140
+ h[4] = (h[4] + e) | 0;
141
+ h[5] = (h[5] + f) | 0;
142
+ h[6] = (h[6] + g) | 0;
143
+ h[7] = (h[7] + hh) | 0;
144
+ }
145
+ const out = new Uint8Array(32);
146
+ const outView = new DataView(out.buffer);
147
+ h.forEach((word, i) => outView.setUint32(i * 4, word));
148
+ return out;
149
+ }
package/src/constants.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /** Protocol constants (SPEC.md §1.2, §9 are normative). */
2
2
 
3
3
  /** Latest SSP2 envelope wire version emitted by current clients. */
4
- export const PROTOCOL_WIRE_VERSION = 3;
4
+ export const PROTOCOL_WIRE_VERSION = 4;
5
5
 
6
- /** Oldest SSP2 envelope wire version accepted. Wire version 3 changed the
7
- * push payload to the sparse row codec (§2.4) and the conflict record shape
8
- * (§6.3); versions 1 and 2 are rejected as unknown (§9). */
9
- export const MINIMUM_PROTOCOL_WIRE_VERSION = 3;
6
+ /** Oldest SSP2 envelope wire version accepted. Wire version 4 replaced
7
+ * per-table subscriptions with units and the replica cursor (§4); earlier
8
+ * versions are rejected as unknown (§9). */
9
+ export const MINIMUM_PROTOCOL_WIRE_VERSION = 4;
10
10
 
11
11
  /** Wire versions implemented by the reference codec, oldest first. */
12
12
  export const SUPPORTED_PROTOCOL_WIRE_VERSIONS: readonly number[] =
package/src/frames.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 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
  } as const;
20
23
 
@@ -25,8 +28,9 @@ export const REQUEST_FRAME_TYPES: ReadonlySet<number> = new Set([
25
28
  FrameType.REQ_HEADER,
26
29
  FrameType.PUSH_COMMIT,
27
30
  FrameType.PULL_HEADER,
28
- FrameType.SUBSCRIPTION,
31
+ FrameType.UNIT,
29
32
  FrameType.ROUND_FAILURE,
33
+ FrameType.PROGRESS,
30
34
  ]);
31
35
 
32
36
  /** Known response frame types (excluding END). */
@@ -40,10 +44,12 @@ export const RESPONSE_FRAME_TYPES: ReadonlySet<number> = new Set([
40
44
  FrameType.SUB_END,
41
45
  FrameType.LEASE,
42
46
  FrameType.PUSH_RESULT_DETAILS,
47
+ FrameType.LIVE_START,
48
+ FrameType.LIVE_END,
43
49
  FrameType.ERROR,
44
50
  ]);
45
51
 
46
- /** Every frame type with a defined layout in wire versions 1 and 2. */
52
+ /** Every frame type with a defined layout in wire version 4. */
47
53
  export const KNOWN_FRAME_TYPES: ReadonlySet<number> = new Set([
48
54
  FrameType.END,
49
55
  ...REQUEST_FRAME_TYPES,