@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.
@@ -34,6 +34,8 @@ export interface RealtimeHeartbeatEvent {
34
34
  }
35
35
  /** §8.6.2 presence fanout kind — a closed set of three. */
36
36
  export type PresenceKind = 'join' | 'update' | 'leave';
37
+ /** §8.6.2 publisher-directed presence rejection code — a closed set of two. */
38
+ export type PresenceErrorCode = 'presence.too_large' | 'presence.forbidden';
37
39
  /**
38
40
  * Server → client presence fanout (§8.6.2): a scope-mate's presence for a
39
41
  * key the receiver holds changed. `doc` is the peer's document for
@@ -49,7 +51,7 @@ export interface RealtimePresenceEvent {
49
51
  actorId?: string;
50
52
  clientId?: string;
51
53
  doc?: Record<string, unknown> | null;
52
- error?: string;
54
+ error?: PresenceErrorCode;
53
55
  timestamp?: number;
54
56
  };
55
57
  }
@@ -59,14 +61,6 @@ export interface RealtimeAck {
59
61
  type: 'ack';
60
62
  cursor: number;
61
63
  }
62
- /** Client → server presence publish/leave (§8.6.2). `doc: null` = leave. */
63
- export interface RealtimePresencePublish {
64
- event: 'presence';
65
- data: {
66
- scopeKey: string;
67
- doc: Record<string, unknown> | null;
68
- };
69
- }
70
64
  export type ParsedRealtimeEvent = {
71
65
  known: true;
72
66
  event: RealtimeServerEvent;
@@ -82,11 +76,3 @@ export declare function encodePresenceFanout(scopeKey: string, kind: PresenceKin
82
76
  * (§8.6.2 `presence.too_large` / `presence.forbidden`). */
83
77
  export declare function encodePresenceError(scopeKey: string, error: string, timestamp: number): string;
84
78
  export declare function parseRealtimeServerEvent(text: string): ParsedRealtimeEvent;
85
- /**
86
- * Parse a client → server presence publish/leave (§8.6.2). Used by the
87
- * server to validate inbound `presence` control messages. A malformed
88
- * message (missing/empty `scopeKey`, or a `doc` that is neither an object
89
- * nor `null`) throws a `DecodeError` — a known event with wrong-shape data
90
- * is never a tolerated variant (§8.1).
91
- */
92
- export declare function parseRealtimePresencePublish(text: string): RealtimePresencePublish;
package/dist/realtime.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * `known: false` for unrecognized event names instead of failing.
10
10
  */
11
11
  import { DecodeError } from './errors.js';
12
+ import { parseJsonDocument } from './json-document.js';
12
13
  /** §8.7 channel tag: a standalone SSP2 response message — a delta. */
13
14
  export const REALTIME_TAG_DELTA = 0x00;
14
15
  /** §8.7 channel tag: one chunk of the in-flight sync round's byte
@@ -67,14 +68,7 @@ const WAKE_REASONS = [
67
68
  'reset-required',
68
69
  ];
69
70
  export function parseRealtimeServerEvent(text) {
70
- let value;
71
- try {
72
- value = JSON.parse(text);
73
- }
74
- catch {
75
- malformed('not valid JSON');
76
- }
77
- const root = requireObject(value, 'control message');
71
+ const root = requireObject(parseJsonDocument(text, 'control message'), 'control message');
78
72
  const event = requireString(root.event, 'event');
79
73
  if (event === 'hello') {
80
74
  const data = requireObject(root.data, 'hello.data');
@@ -194,32 +188,3 @@ function requirePresenceDocObject(value) {
194
188
  malformed('presence.data.doc must be a JSON object');
195
189
  }
196
190
  }
197
- /**
198
- * Parse a client → server presence publish/leave (§8.6.2). Used by the
199
- * server to validate inbound `presence` control messages. A malformed
200
- * message (missing/empty `scopeKey`, or a `doc` that is neither an object
201
- * nor `null`) throws a `DecodeError` — a known event with wrong-shape data
202
- * is never a tolerated variant (§8.1).
203
- */
204
- export function parseRealtimePresencePublish(text) {
205
- let value;
206
- try {
207
- value = JSON.parse(text);
208
- }
209
- catch {
210
- malformed('not valid JSON');
211
- }
212
- const root = requireObject(value, 'control message');
213
- if (root.event !== 'presence') {
214
- malformed('not a presence control message');
215
- }
216
- const data = requireObject(root.data, 'presence.data');
217
- const scopeKey = validatePresenceScopeKey(data.scopeKey);
218
- const doc = data.doc;
219
- if (doc !== null)
220
- requirePresenceDocObject(doc);
221
- return {
222
- event: 'presence',
223
- data: { scopeKey, doc: doc },
224
- };
225
- }
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 {
@@ -49,7 +49,10 @@ export declare function encodeSparseRow(columns: readonly RowColumn[], primaryKe
49
49
  *
50
50
  * Decode checks, in order (each a decode error, RFC §6.1): a set presence
51
51
  * padding bit, an absent primary-key column, a set null-bitmap padding bit
52
- * (a null for an absent column), a null bit for a non-nullable column.
52
+ * (a null for an absent column), a null bit for a non-nullable column. Each
53
+ * present value must also be a push admission value (SPEC.md §2.4): no
54
+ * U+0000 in a `string`, a `json` value under the JSON validity rule, a finite
55
+ * `float`, a canonical `blob_ref`.
53
56
  */
54
57
  export declare function decodeSparseRow(columns: readonly RowColumn[], primaryKeyIndex: number, bytes: Uint8Array): SparseRowValue[];
55
58
  export declare function writeRow(writer: ByteWriter, columns: readonly RowColumn[], values: readonly RowValue[]): void;
package/dist/row-codec.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { parseBlobRef } from './blob-ref.js';
16
16
  import { ByteReader, ByteWriter } from './bytes.js';
17
17
  import { DecodeError, EncodeError } from './errors.js';
18
+ import { parseJsonDocument } from './json-document.js';
18
19
  /** Wire tags, unchanged from v1's binary-table-v1 assignment (SPEC.md §2.4). */
19
20
  const TYPE_TO_TAG = {
20
21
  string: 1,
@@ -82,9 +83,10 @@ function readValue(reader, column) {
82
83
  case 'string':
83
84
  return reader.str();
84
85
  case 'json': {
85
- // Conventions `json` MUST, applied at row-codec decode (SPEC.md §2.4
86
- // tag 5): the value must parse as a JSON document; the raw string is
87
- // preserved verbatim for round-trip fidelity.
86
+ // SPEC.md §2.4 tag 5: the value must parse as a JSON document; the raw
87
+ // string is preserved verbatim for round-trip fidelity. Full rows stay
88
+ // this permissive so stored rows keep decoding; `decodeSparseRow`
89
+ // applies the JSON validity rule on top.
88
90
  const raw = reader.str();
89
91
  try {
90
92
  JSON.parse(raw);
@@ -96,8 +98,8 @@ function readValue(reader, column) {
96
98
  }
97
99
  case 'blob_ref': {
98
100
  // §2.4 tag 7: the value is a canonical BlobRef JSON document
99
- // (§5.9.1). Validated at decode, same class as tag-5 json; the raw
100
- // string is preserved verbatim for re-encoding.
101
+ // (§5.9.1), checked on every decode path; the raw string is preserved
102
+ // verbatim for re-encoding.
101
103
  const raw = reader.str();
102
104
  parseBlobRef(raw);
103
105
  return raw;
@@ -176,7 +178,10 @@ export function encodeSparseRow(columns, primaryKeyIndex, values) {
176
178
  *
177
179
  * Decode checks, in order (each a decode error, RFC §6.1): a set presence
178
180
  * padding bit, an absent primary-key column, a set null-bitmap padding bit
179
- * (a null for an absent column), a null bit for a non-nullable column.
181
+ * (a null for an absent column), a null bit for a non-nullable column. Each
182
+ * present value must also be a push admission value (SPEC.md §2.4): no
183
+ * U+0000 in a `string`, a `json` value under the JSON validity rule, a finite
184
+ * `float`, a canonical `blob_ref`.
180
185
  */
181
186
  export function decodeSparseRow(columns, primaryKeyIndex, bytes) {
182
187
  const reader = new ByteReader(bytes);
@@ -217,7 +222,24 @@ export function decodeSparseRow(columns, primaryKeyIndex, bytes) {
217
222
  const isNull = bitmapBit(nulls, present);
218
223
  present++;
219
224
  if (!isNull) {
220
- values.push(readValue(reader, column));
225
+ const value = readValue(reader, column);
226
+ // Push admission values (SPEC.md §2.4): one value set every storage
227
+ // backend and core accepts. `blob_ref` canonicality is already checked
228
+ // by `readValue` on every path.
229
+ if (column.type === 'string' &&
230
+ typeof value === 'string' &&
231
+ value.includes('\u0000')) {
232
+ throw new DecodeError('sync.invalid_request', `string column ${column.name} holds U+0000`);
233
+ }
234
+ if (column.type === 'json' && typeof value === 'string') {
235
+ parseJsonDocument(value, `json column ${column.name}`);
236
+ }
237
+ if (column.type === 'float' &&
238
+ typeof value === 'number' &&
239
+ !Number.isFinite(value)) {
240
+ throw new DecodeError('sync.invalid_request', `float column ${column.name} holds NaN or an infinity`);
241
+ }
242
+ values.push(value);
221
243
  continue;
222
244
  }
223
245
  if (!column.nullable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/core",
3
- "version": "0.58.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",
package/src/blob-ref.ts CHANGED
@@ -2,15 +2,14 @@
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';
12
+ import { parseJsonDocument } from './json-document';
14
13
 
15
14
  /** The content-address prefix, identical in form to `segmentId` (§5.1). */
16
15
  const BLOB_ID_RE = /^sha256:[0-9a-f]{64}$/;
@@ -26,30 +25,14 @@ export interface BlobRef {
26
25
  readonly name?: string;
27
26
  }
28
27
 
29
- /** Canonical key order (§5.9.1): blobId, byteLength, mediaType, name. */
30
- const CANONICAL_KEYS: readonly string[] = [
31
- 'blobId',
32
- 'byteLength',
33
- 'mediaType',
34
- 'name',
35
- ];
36
-
37
28
  /**
38
- * Parse and validate a raw `blob_ref` string against the §5.9.1 shape. A
39
- * failure is a row-codec decode error (`sync.invalid_request`), the same
40
- * class as the tag-5 json parse failure — so codec and §11 rendering can
41
- * never disagree.
29
+ * Parse and validate a raw `blob_ref` string (§5.9.1): a valid JSON document
30
+ * (Conventions JSON validity rule), the pinned shape, and text byte-identical
31
+ * to `serializeBlobRef` of the parsed fields. A failure is a row-codec decode
32
+ * error (`sync.invalid_request`) on every decode path.
42
33
  */
43
34
  export function parseBlobRef(raw: string): BlobRef {
44
- let parsed: unknown;
45
- try {
46
- parsed = JSON.parse(raw);
47
- } catch {
48
- throw new DecodeError(
49
- 'sync.invalid_request',
50
- 'blob_ref value does not parse as a JSON document (§5.9.1)',
51
- );
52
- }
35
+ const parsed = parseJsonDocument(raw, 'blob_ref value');
53
36
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
54
37
  throw new DecodeError(
55
38
  'sync.invalid_request',
@@ -57,13 +40,12 @@ export function parseBlobRef(raw: string): BlobRef {
57
40
  );
58
41
  }
59
42
  const obj = parsed as Record<string, unknown>;
60
- for (const key of Object.keys(obj)) {
61
- if (!CANONICAL_KEYS.includes(key)) {
62
- throw new DecodeError(
63
- 'sync.invalid_request',
64
- `blob_ref has unknown key ${JSON.stringify(key)} (§5.9.1)`,
65
- );
66
- }
43
+ const keys = ['blobId', 'byteLength', 'mediaType', 'name'];
44
+ if (Object.keys(obj).some((key) => !keys.includes(key))) {
45
+ throw new DecodeError(
46
+ 'sync.invalid_request',
47
+ 'blob_ref has an unknown key (§5.9.1)',
48
+ );
67
49
  }
68
50
  const { blobId, byteLength, mediaType, name } = obj;
69
51
  if (typeof blobId !== 'string' || !BLOB_ID_RE.test(blobId)) {
@@ -94,23 +76,21 @@ export function parseBlobRef(raw: string): BlobRef {
94
76
  'blob_ref.name must be a string when present (§5.9.1)',
95
77
  );
96
78
  }
97
- // Canonical key order: the raw string's keys, in appearance order, must be
98
- // a prefix-consistent subsequence of CANONICAL_KEYS (present keys in the
99
- // pinned order, absent keys omitted).
100
- const presentInOrder = Object.keys(obj);
101
- const expectedOrder = CANONICAL_KEYS.filter((k) => Object.hasOwn(obj, k));
102
- if (presentInOrder.join(',') !== expectedOrder.join(',')) {
103
- throw new DecodeError(
104
- 'sync.invalid_request',
105
- 'blob_ref keys are not in canonical order (§5.9.1)',
106
- );
107
- }
108
- return {
79
+ const ref: BlobRef = {
109
80
  blobId,
110
81
  byteLength,
111
82
  ...(mediaType !== undefined ? { mediaType } : {}),
112
83
  ...(name !== undefined ? { name } : {}),
113
84
  };
85
+ // Reordered or duplicate keys, whitespace, and a byteLength written as
86
+ // `1e2`, `3.0` or `-0` all differ from the canonical text.
87
+ if (serializeBlobRef(ref) !== raw) {
88
+ throw new DecodeError(
89
+ 'sync.invalid_request',
90
+ 'blob_ref is not in canonical form (§5.9.1)',
91
+ );
92
+ }
93
+ return ref;
114
94
  }
115
95
 
116
96
  /** Serialize a BlobRef to its canonical string form (§5.9.1 key order). */
@@ -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/crypto.ts 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';
16
+ import { parseJsonDocument } from './json-document';
16
17
 
17
18
  /** The pre-flip application type of an encrypted column (SPEC.md §5.11). */
18
19
  export type DeclaredType =
@@ -150,7 +151,7 @@ export function deserializePlain(
150
151
  }
151
152
  if (declaredType === 'json' || declaredType === 'blob_ref') {
152
153
  try {
153
- JSON.parse(text);
154
+ parseJsonDocument(text, `decrypted ${declaredType} value`);
154
155
  } catch {
155
156
  throw new DecryptError(
156
157
  `decrypted ${declaredType} value does not parse`,
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,
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export * from './constants';
13
13
  export * from './crypto';
14
14
  export * from './errors';
15
15
  export * from './frames';
16
+ export * from './json-document';
16
17
  export * from './message';
17
18
  export * from './realtime';
18
19
  export * from './rejection-details';