@syncular/core 0.58.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.
@@ -9,10 +9,10 @@ export interface BlobRef {
9
9
  readonly name?: string;
10
10
  }
11
11
  /**
12
- * Parse and validate a raw `blob_ref` string against the §5.9.1 shape. A
13
- * failure is a row-codec decode error (`sync.invalid_request`), the same
14
- * class as the tag-5 json parse failure — so codec and §11 rendering can
15
- * never disagree.
12
+ * Parse and validate a raw `blob_ref` string (§5.9.1): a valid JSON document
13
+ * (Conventions JSON validity rule), the pinned shape, and text byte-identical
14
+ * to `serializeBlobRef` of the parsed fields. A failure is a row-codec decode
15
+ * error (`sync.invalid_request`) on every decode path.
16
16
  */
17
17
  export declare function parseBlobRef(raw: string): BlobRef;
18
18
  /** Serialize a BlobRef to its canonical string form (§5.9.1 key order). */
package/dist/blob-ref.js CHANGED
@@ -2,46 +2,31 @@
2
2
  * Canonical BlobRef documents (SPEC.md §5.9.1) — the value carried by a
3
3
  * `blob_ref` column (§2.4 tag 7).
4
4
  *
5
- * A `blob_ref` value is a `str` holding a JSON document with a pinned shape
6
- * and pinned key order: `blobId` (content address), `byteLength`, then the
7
- * optional `mediaType` and `name`. On the wire and in the codec it is
8
- * byte-for-byte a `str`, validated at decode exactly like `json` (tag 5)
9
- * plus the shape check below; the raw string is preserved verbatim for
10
- * re-encoding. Nothing here re-serializes a decoded value — round-trip
11
- * fidelity is the same rule the `json` tag obeys.
5
+ * A `blob_ref` value is a `str` holding the canonical serialization of a
6
+ * pinned shape: `blobId` (content address), `byteLength`, then the optional
7
+ * `mediaType` and `name`. On the wire and in the codec it is byte-for-byte a
8
+ * `str`; decode accepts exactly the canonical text, so the raw string a
9
+ * decoder preserves for re-encoding is the canonical one.
12
10
  */
13
11
  import { DecodeError } from './errors.js';
12
+ import { parseJsonDocument } from './json-document.js';
14
13
  /** The content-address prefix, identical in form to `segmentId` (§5.1). */
15
14
  const BLOB_ID_RE = /^sha256:[0-9a-f]{64}$/;
16
- /** Canonical key order (§5.9.1): blobId, byteLength, mediaType, name. */
17
- const CANONICAL_KEYS = [
18
- 'blobId',
19
- 'byteLength',
20
- 'mediaType',
21
- 'name',
22
- ];
23
15
  /**
24
- * Parse and validate a raw `blob_ref` string against the §5.9.1 shape. A
25
- * failure is a row-codec decode error (`sync.invalid_request`), the same
26
- * class as the tag-5 json parse failure — so codec and §11 rendering can
27
- * never disagree.
16
+ * Parse and validate a raw `blob_ref` string (§5.9.1): a valid JSON document
17
+ * (Conventions JSON validity rule), the pinned shape, and text byte-identical
18
+ * to `serializeBlobRef` of the parsed fields. A failure is a row-codec decode
19
+ * error (`sync.invalid_request`) on every decode path.
28
20
  */
29
21
  export function parseBlobRef(raw) {
30
- let parsed;
31
- try {
32
- parsed = JSON.parse(raw);
33
- }
34
- catch {
35
- throw new DecodeError('sync.invalid_request', 'blob_ref value does not parse as a JSON document (§5.9.1)');
36
- }
22
+ const parsed = parseJsonDocument(raw, 'blob_ref value');
37
23
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
38
24
  throw new DecodeError('sync.invalid_request', 'blob_ref value must be a JSON object (§5.9.1)');
39
25
  }
40
26
  const obj = parsed;
41
- for (const key of Object.keys(obj)) {
42
- if (!CANONICAL_KEYS.includes(key)) {
43
- throw new DecodeError('sync.invalid_request', `blob_ref has unknown key ${JSON.stringify(key)} (§5.9.1)`);
44
- }
27
+ const keys = ['blobId', 'byteLength', 'mediaType', 'name'];
28
+ if (Object.keys(obj).some((key) => !keys.includes(key))) {
29
+ throw new DecodeError('sync.invalid_request', 'blob_ref has an unknown key (§5.9.1)');
45
30
  }
46
31
  const { blobId, byteLength, mediaType, name } = obj;
47
32
  if (typeof blobId !== 'string' || !BLOB_ID_RE.test(blobId)) {
@@ -58,20 +43,18 @@ export function parseBlobRef(raw) {
58
43
  if (name !== undefined && typeof name !== 'string') {
59
44
  throw new DecodeError('sync.invalid_request', 'blob_ref.name must be a string when present (§5.9.1)');
60
45
  }
61
- // Canonical key order: the raw string's keys, in appearance order, must be
62
- // a prefix-consistent subsequence of CANONICAL_KEYS (present keys in the
63
- // pinned order, absent keys omitted).
64
- const presentInOrder = Object.keys(obj);
65
- const expectedOrder = CANONICAL_KEYS.filter((k) => Object.hasOwn(obj, k));
66
- if (presentInOrder.join(',') !== expectedOrder.join(',')) {
67
- throw new DecodeError('sync.invalid_request', 'blob_ref keys are not in canonical order (§5.9.1)');
68
- }
69
- return {
46
+ const ref = {
70
47
  blobId,
71
48
  byteLength,
72
49
  ...(mediaType !== undefined ? { mediaType } : {}),
73
50
  ...(name !== undefined ? { name } : {}),
74
51
  };
52
+ // Reordered or duplicate keys, whitespace, and a byteLength written as
53
+ // `1e2`, `3.0` or `-0` all differ from the canonical text.
54
+ if (serializeBlobRef(ref) !== raw) {
55
+ throw new DecodeError('sync.invalid_request', 'blob_ref is not in canonical form (§5.9.1)');
56
+ }
57
+ return ref;
75
58
  }
76
59
  /** Serialize a BlobRef to its canonical string form (§5.9.1 key order). */
77
60
  export function serializeBlobRef(ref) {
@@ -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/crypto.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * is the symmetric core and the envelope both sides agree on byte-for-byte.
14
14
  */
15
15
  import { ByteReader, ByteWriter } from './bytes.js';
16
+ import { parseJsonDocument } from './json-document.js';
16
17
  /** Envelope version byte (SPEC.md §5.11). */
17
18
  export const ENVELOPE_VERSION = 0x01;
18
19
  /** AES-GCM nonce length (96-bit, the GCM standard). */
@@ -113,7 +114,7 @@ export function deserializePlain(declaredType, bytes) {
113
114
  }
114
115
  if (declaredType === 'json' || declaredType === 'blob_ref') {
115
116
  try {
116
- JSON.parse(text);
117
+ parseJsonDocument(text, `decrypted ${declaredType} value`);
117
118
  }
118
119
  catch {
119
120
  throw new DecryptError(`decrypted ${declaredType} value does not parse`);
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/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export * from './constants.js';
13
13
  export * from './crypto.js';
14
14
  export * from './errors.js';
15
15
  export * from './frames.js';
16
+ export * from './json-document.js';
16
17
  export * from './message.js';
17
18
  export * from './realtime.js';
18
19
  export * from './rejection-details.js';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export * from './constants.js';
13
13
  export * from './crypto.js';
14
14
  export * from './errors.js';
15
15
  export * from './frames.js';
16
+ export * from './json-document.js';
16
17
  export * from './message.js';
17
18
  export * from './realtime.js';
18
19
  export * from './rejection-details.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Parse `raw` as a JSON document under the validity rule, which is defined on
3
+ * the raw text: RFC 8259, at most 64 nested containers anywhere in the text,
4
+ * every number token a finite binary64, no U+0000 and no unpaired surrogate in
5
+ * any string token (key or value), written literally or as a `\u` escape.
6
+ * `JSON.parse` drops values shadowed by a duplicate key, so one lexical pass
7
+ * over the text checks what the parser cannot see. A violation throws
8
+ * `DecodeError` (`sync.invalid_request`); `what` names the field in the
9
+ * message.
10
+ */
11
+ export declare function parseJsonDocument(raw: string, what: string): unknown;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The JSON validity rule (SPEC.md Conventions): the one definition of a valid
3
+ * JSON document for envelope `json` fields, push-payload `json` columns and
4
+ * `blob_ref` values. The Rust core (`ssp2::primitives::parse_json_document`)
5
+ * accepts exactly the same documents.
6
+ */
7
+ import { DecodeError } from './errors.js';
8
+ const MAX_DEPTH = 64;
9
+ /**
10
+ * Parse `raw` as a JSON document under the validity rule, which is defined on
11
+ * the raw text: RFC 8259, at most 64 nested containers anywhere in the text,
12
+ * every number token a finite binary64, no U+0000 and no unpaired surrogate in
13
+ * any string token (key or value), written literally or as a `\u` escape.
14
+ * `JSON.parse` drops values shadowed by a duplicate key, so one lexical pass
15
+ * over the text checks what the parser cannot see. A violation throws
16
+ * `DecodeError` (`sync.invalid_request`); `what` names the field in the
17
+ * message.
18
+ */
19
+ export function parseJsonDocument(raw, what) {
20
+ let value;
21
+ try {
22
+ value = JSON.parse(raw);
23
+ }
24
+ catch {
25
+ throw new DecodeError('sync.invalid_request', `${what} does not parse as a JSON document`);
26
+ }
27
+ let depth = 0;
28
+ for (const [token] of raw.matchAll(/"(?:[^"\\]|\\.)*"|[[{\]}]|-?\d[\d.eE+-]*/g)) {
29
+ const head = token[0];
30
+ if (head === '[' || head === '{') {
31
+ if (++depth > MAX_DEPTH) {
32
+ throw new DecodeError('sync.invalid_request', `${what} nests deeper than ${MAX_DEPTH} containers`);
33
+ }
34
+ }
35
+ else if (head === ']' || head === '}') {
36
+ depth--;
37
+ }
38
+ else if (head === '"') {
39
+ // In `u` mode `\p{Cs}` matches only a surrogate outside a pair.
40
+ const text = token.includes('\\') ? JSON.parse(token) : token;
41
+ if (text.includes('\u0000') || /\p{Cs}/u.test(text)) {
42
+ throw new DecodeError('sync.invalid_request', `${what} holds a string with U+0000 or an unpaired surrogate`);
43
+ }
44
+ }
45
+ else if (!Number.isFinite(Number(token))) {
46
+ throw new DecodeError('sync.invalid_request', `${what} holds a number outside finite binary64`);
47
+ }
48
+ }
49
+ return value;
50
+ }
package/dist/message.d.ts CHANGED
@@ -4,8 +4,11 @@ export interface ReqHeaderFrame {
4
4
  type: 'REQ_HEADER';
5
5
  clientId: string;
6
6
  schemaVersion: number;
7
- /** Wire v2 partition log identity; absent requests epoch acquisition. */
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,15 +68,15 @@ 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;
60
75
  latestSchemaVersion?: number;
61
- /** Required and non-empty on wire v2. */
62
- logEpoch?: string;
63
- /** Required on wire v2. True produces a header-only reset response. */
64
- resetRequired?: boolean;
76
+ /** Partition log identity, non-empty. */
77
+ logEpoch: string;
78
+ /** True produces a header-only reset response. */
79
+ resetRequired: boolean;
65
80
  }
66
81
  /** §7.3.2: a server-issued auth lease delivered to the client (opaque). */
67
82
  export interface LeaseFrame {
@@ -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;