@syncular/core 0.58.0 → 0.59.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) {
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/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,7 +4,7 @@ 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
9
  }
10
10
  export interface PushOperation {
@@ -58,10 +58,10 @@ export interface RespHeaderFrame {
58
58
  type: 'RESP_HEADER';
59
59
  requiredSchemaVersion?: number;
60
60
  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;
61
+ /** Partition log identity, non-empty. */
62
+ logEpoch: string;
63
+ /** True produces a header-only reset response. */
64
+ resetRequired: boolean;
65
65
  }
66
66
  /** §7.3.2: a server-issued auth lease delivered to the client (opaque). */
67
67
  export interface LeaseFrame {
package/dist/message.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { ByteReader, ByteWriter, readStringListMap, readStringMap, utf8Encode, writeStringListMap, writeStringMap, } from './bytes.js';
15
15
  import { isSupportedProtocolWireVersion, SYNC_PACK_MAGIC } from './constants.js';
16
16
  import { DecodeError } from './errors.js';
17
+ import { parseJsonDocument } from './json-document.js';
17
18
  import { FrameType, KNOWN_FRAME_TYPES, REQUEST_FRAME_TYPES, RESPONSE_FRAME_TYPES, } from './frames.js';
18
19
  import { normalizeRejectionDetails, } from './rejection-details.js';
19
20
  import { decodeRowsSegment } from './segment.js';
@@ -28,13 +29,10 @@ function definedObject(record) {
28
29
  function invalid(message) {
29
30
  throw new DecodeError('sync.invalid_request', message);
30
31
  }
31
- function requireJsonDocument(raw, what) {
32
- try {
33
- JSON.parse(raw);
34
- }
35
- catch {
36
- invalid(`${what} is not a valid JSON document`);
37
- }
32
+ /** A `json` field: a valid JSON document (Conventions), kept as raw text. */
33
+ function readJson(r, what) {
34
+ const raw = r.str();
35
+ parseJsonDocument(raw, what);
38
36
  return raw;
39
37
  }
40
38
  /** §1.5: a `ROUND_FAILURE` code is a lowercase dotted catalog code. */
@@ -53,21 +51,19 @@ function validateRoundFailureCode(code, fail) {
53
51
  // ---------------------------------------------------------------------------
54
52
  // Frame payload decoders (each consumes exactly the frame payload)
55
53
  // ---------------------------------------------------------------------------
56
- function decodeReqHeader(r, wireVersion) {
54
+ function decodeReqHeader(r) {
57
55
  const clientId = r.str();
58
56
  if (clientId.length === 0)
59
57
  invalid('REQ_HEADER.clientId must be non-empty');
60
58
  const schemaVersion = r.i32();
61
59
  if (schemaVersion < 1)
62
60
  invalid('REQ_HEADER.schemaVersion must be >= 1');
63
- const logEpoch = wireVersion >= 2
64
- ? r.opt(() => {
65
- const value = r.str();
66
- if (value.length === 0)
67
- invalid('REQ_HEADER.logEpoch must be non-empty');
68
- return value;
69
- })
70
- : undefined;
61
+ const logEpoch = r.opt(() => {
62
+ const value = r.str();
63
+ if (value.length === 0)
64
+ invalid('REQ_HEADER.logEpoch must be non-empty');
65
+ return value;
66
+ });
71
67
  return definedObject({
72
68
  type: 'REQ_HEADER',
73
69
  clientId,
@@ -139,9 +135,9 @@ function decodeSubscription(r) {
139
135
  const id = r.str();
140
136
  const table = r.str();
141
137
  const scopes = readStringListMap(r);
142
- const params = r.opt(() => requireJsonDocument(r.str(), 'SUBSCRIPTION.params'));
138
+ const params = r.opt(() => readJson(r, 'SUBSCRIPTION.params'));
143
139
  const cursor = r.i64();
144
- const bootstrapState = r.opt(() => requireJsonDocument(r.str(), 'SUBSCRIPTION.bootstrapState'));
140
+ const bootstrapState = r.opt(() => readJson(r, 'SUBSCRIPTION.bootstrapState'));
145
141
  return definedObject({
146
142
  type: 'SUBSCRIPTION',
147
143
  id,
@@ -152,20 +148,20 @@ function decodeSubscription(r) {
152
148
  bootstrapState: bootstrapState,
153
149
  });
154
150
  }
155
- function decodeRespHeader(r, wireVersion) {
151
+ function decodeRespHeader(r) {
156
152
  const requiredSchemaVersion = r.opt(() => r.i32());
157
153
  const latestSchemaVersion = r.opt(() => r.i32());
158
- const logEpoch = wireVersion >= 2 ? r.str() : undefined;
159
- if (logEpoch !== undefined && logEpoch.length === 0) {
154
+ const logEpoch = r.str();
155
+ if (logEpoch.length === 0) {
160
156
  invalid('RESP_HEADER.logEpoch must be non-empty');
161
157
  }
162
- const resetRequired = wireVersion >= 2 ? r.bool() : undefined;
158
+ const resetRequired = r.bool();
163
159
  return definedObject({
164
160
  type: 'RESP_HEADER',
165
161
  requiredSchemaVersion: requiredSchemaVersion,
166
162
  latestSchemaVersion: latestSchemaVersion,
167
- logEpoch: logEpoch,
168
- resetRequired: resetRequired,
163
+ logEpoch,
164
+ resetRequired,
169
165
  });
170
166
  }
171
167
  function decodeLease(r) {
@@ -250,9 +246,10 @@ function decodePushResultDetails(r) {
250
246
  invalid('PUSH_RESULT_DETAILS opIndex values must be unique and non-negative');
251
247
  }
252
248
  seen.add(opIndex);
249
+ const parsed = parseJsonDocument(r.str(), 'PUSH_RESULT_DETAILS.details');
253
250
  let details;
254
251
  try {
255
- details = normalizeRejectionDetails(JSON.parse(r.str()));
252
+ details = normalizeRejectionDetails(parsed);
256
253
  }
257
254
  catch (error) {
258
255
  invalid(`invalid PUSH_RESULT_DETAILS.details: ${error instanceof Error ? error.message : String(error)}`);
@@ -375,7 +372,7 @@ function decodeSegmentInline(r) {
375
372
  }
376
373
  function decodeSubEnd(r) {
377
374
  const nextCursor = r.i64();
378
- const bootstrapState = r.opt(() => requireJsonDocument(r.str(), 'SUB_END.bootstrapState'));
375
+ const bootstrapState = r.opt(() => readJson(r, 'SUB_END.bootstrapState'));
379
376
  return definedObject({
380
377
  type: 'SUB_END',
381
378
  nextCursor,
@@ -388,7 +385,7 @@ function decodeErrorFrame(r) {
388
385
  const category = r.str();
389
386
  const retryable = r.bool();
390
387
  const recommendedAction = r.str();
391
- const details = r.opt(() => requireJsonDocument(r.str(), 'ERROR.details'));
388
+ const details = r.opt(() => readJson(r, 'ERROR.details'));
392
389
  return definedObject({
393
390
  type: 'ERROR',
394
391
  code,
@@ -399,12 +396,12 @@ function decodeErrorFrame(r) {
399
396
  details: details,
400
397
  });
401
398
  }
402
- function decodeRequestFrame(frameType, payload, wireVersion) {
399
+ function decodeRequestFrame(frameType, payload) {
403
400
  const r = new ByteReader(payload);
404
401
  let frame;
405
402
  switch (frameType) {
406
403
  case FrameType.REQ_HEADER:
407
- frame = decodeReqHeader(r, wireVersion);
404
+ frame = decodeReqHeader(r);
408
405
  break;
409
406
  case FrameType.ROUND_FAILURE:
410
407
  frame = decodeRoundFailure(r);
@@ -427,12 +424,12 @@ function decodeRequestFrame(frameType, payload, wireVersion) {
427
424
  r.expectFullyConsumed(`${frame.type} frame payload`);
428
425
  return frame;
429
426
  }
430
- function decodeResponseFrame(frameType, payload, wireVersion) {
427
+ function decodeResponseFrame(frameType, payload) {
431
428
  const r = new ByteReader(payload);
432
429
  let frame;
433
430
  switch (frameType) {
434
431
  case FrameType.RESP_HEADER:
435
- frame = decodeRespHeader(r, wireVersion);
432
+ frame = decodeRespHeader(r);
436
433
  break;
437
434
  case FrameType.LEASE:
438
435
  frame = decodeLease(r);
@@ -641,7 +638,7 @@ const MEDIA_TYPE_BYTES = { rows: 1, sqlite: 2 };
641
638
  function encodeInvalid(message) {
642
639
  throw new Error(message);
643
640
  }
644
- function encodeFrame(frame, wireVersion) {
641
+ function encodeFrame(frame) {
645
642
  const w = new ByteWriter();
646
643
  switch (frame.type) {
647
644
  case 'REQ_HEADER': {
@@ -653,15 +650,10 @@ function encodeFrame(frame, wireVersion) {
653
650
  }
654
651
  w.str(frame.clientId);
655
652
  w.i32(frame.schemaVersion);
656
- if (wireVersion >= 2) {
657
- if (frame.logEpoch === '') {
658
- encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
659
- }
660
- w.opt(frame.logEpoch, (v) => w.str(v));
661
- }
662
- else if (frame.logEpoch !== undefined) {
663
- encodeInvalid('REQ_HEADER.logEpoch requires wireVersion 2');
653
+ if (frame.logEpoch === '') {
654
+ encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
664
655
  }
656
+ w.opt(frame.logEpoch, (v) => w.str(v));
665
657
  break;
666
658
  }
667
659
  case 'ROUND_FAILURE': {
@@ -715,20 +707,11 @@ function encodeFrame(frame, wireVersion) {
715
707
  case 'RESP_HEADER': {
716
708
  w.opt(frame.requiredSchemaVersion, (v) => w.i32(v));
717
709
  w.opt(frame.latestSchemaVersion, (v) => w.i32(v));
718
- if (wireVersion >= 2) {
719
- if (frame.logEpoch === undefined || frame.logEpoch.length === 0) {
720
- encodeInvalid('RESP_HEADER.logEpoch is required and non-empty');
721
- }
722
- if (frame.resetRequired === undefined) {
723
- encodeInvalid('RESP_HEADER.resetRequired is required');
724
- }
725
- w.str(frame.logEpoch);
726
- w.bool(frame.resetRequired);
727
- }
728
- else if (frame.logEpoch !== undefined ||
729
- frame.resetRequired !== undefined) {
730
- encodeInvalid('RESP_HEADER epoch fields require wireVersion 2');
710
+ if (frame.logEpoch.length === 0) {
711
+ encodeInvalid('RESP_HEADER.logEpoch must be non-empty');
731
712
  }
713
+ w.str(frame.logEpoch);
714
+ w.bool(frame.resetRequired);
732
715
  break;
733
716
  }
734
717
  case 'LEASE': {
@@ -892,7 +875,7 @@ export function encodeMessage(message) {
892
875
  w.u8(message.msgKind === 'request' ? 0x01 : 0x02);
893
876
  w.u8(0x00);
894
877
  for (const frame of message.frames) {
895
- const { frameType, payload } = encodeFrame(frame, message.wireVersion);
878
+ const { frameType, payload } = encodeFrame(frame);
896
879
  w.u8(frameType);
897
880
  w.u32(payload.length);
898
881
  w.raw(payload);
@@ -948,7 +931,7 @@ export function decodeMessage(bytes) {
948
931
  const message = {
949
932
  wireVersion,
950
933
  msgKind: 'request',
951
- frames: readFrames(r, (frameType, payload) => decodeRequestFrame(frameType, payload, wireVersion)),
934
+ frames: readFrames(r, (frameType, payload) => decodeRequestFrame(frameType, payload)),
952
935
  };
953
936
  validateRequestSequence(message.frames);
954
937
  return message;
@@ -956,7 +939,7 @@ export function decodeMessage(bytes) {
956
939
  const message = {
957
940
  wireVersion,
958
941
  msgKind: 'response',
959
- frames: readFrames(r, (frameType, payload) => decodeResponseFrame(frameType, payload, wireVersion)),
942
+ frames: readFrames(r, (frameType, payload) => decodeResponseFrame(frameType, payload)),
960
943
  };
961
944
  validateResponseSequence(message.frames);
962
945
  return message;
@@ -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
- }
@@ -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.59.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). */
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/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';
@@ -0,0 +1,62 @@
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';
8
+
9
+ const MAX_DEPTH = 64;
10
+
11
+ /**
12
+ * Parse `raw` as a JSON document under the validity rule, which is defined on
13
+ * the raw text: RFC 8259, at most 64 nested containers anywhere in the text,
14
+ * every number token a finite binary64, no U+0000 and no unpaired surrogate in
15
+ * any string token (key or value), written literally or as a `\u` escape.
16
+ * `JSON.parse` drops values shadowed by a duplicate key, so one lexical pass
17
+ * over the text checks what the parser cannot see. A violation throws
18
+ * `DecodeError` (`sync.invalid_request`); `what` names the field in the
19
+ * message.
20
+ */
21
+ export function parseJsonDocument(raw: string, what: string): unknown {
22
+ let value: unknown;
23
+ try {
24
+ value = JSON.parse(raw);
25
+ } catch {
26
+ throw new DecodeError(
27
+ 'sync.invalid_request',
28
+ `${what} does not parse as a JSON document`,
29
+ );
30
+ }
31
+ let depth = 0;
32
+ for (const [token] of raw.matchAll(
33
+ /"(?:[^"\\]|\\.)*"|[[{\]}]|-?\d[\d.eE+-]*/g,
34
+ )) {
35
+ const head = token[0];
36
+ if (head === '[' || head === '{') {
37
+ if (++depth > MAX_DEPTH) {
38
+ throw new DecodeError(
39
+ 'sync.invalid_request',
40
+ `${what} nests deeper than ${MAX_DEPTH} containers`,
41
+ );
42
+ }
43
+ } else if (head === ']' || head === '}') {
44
+ depth--;
45
+ } else if (head === '"') {
46
+ // In `u` mode `\p{Cs}` matches only a surrogate outside a pair.
47
+ const text: string = token.includes('\\') ? JSON.parse(token) : token;
48
+ if (text.includes('\u0000') || /\p{Cs}/u.test(text)) {
49
+ throw new DecodeError(
50
+ 'sync.invalid_request',
51
+ `${what} holds a string with U+0000 or an unpaired surrogate`,
52
+ );
53
+ }
54
+ } else if (!Number.isFinite(Number(token))) {
55
+ throw new DecodeError(
56
+ 'sync.invalid_request',
57
+ `${what} holds a number outside finite binary64`,
58
+ );
59
+ }
60
+ }
61
+ return value;
62
+ }
package/src/message.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from './bytes';
23
23
  import { isSupportedProtocolWireVersion, SYNC_PACK_MAGIC } from './constants';
24
24
  import { DecodeError } from './errors';
25
+ import { parseJsonDocument } from './json-document';
25
26
  import {
26
27
  FrameType,
27
28
  KNOWN_FRAME_TYPES,
@@ -42,7 +43,7 @@ export interface ReqHeaderFrame {
42
43
  type: 'REQ_HEADER';
43
44
  clientId: string;
44
45
  schemaVersion: number;
45
- /** Wire v2 partition log identity; absent requests epoch acquisition. */
46
+ /** Partition log identity; absent requests epoch acquisition. */
46
47
  logEpoch?: string;
47
48
  }
48
49
 
@@ -110,10 +111,10 @@ export interface RespHeaderFrame {
110
111
  type: 'RESP_HEADER';
111
112
  requiredSchemaVersion?: number;
112
113
  latestSchemaVersion?: number;
113
- /** Required and non-empty on wire v2. */
114
- logEpoch?: string;
115
- /** Required on wire v2. True produces a header-only reset response. */
116
- resetRequired?: boolean;
114
+ /** Partition log identity, non-empty. */
115
+ logEpoch: string;
116
+ /** True produces a header-only reset response. */
117
+ resetRequired: boolean;
117
118
  }
118
119
 
119
120
  /** §7.3.2: a server-issued auth lease delivered to the client (opaque). */
@@ -286,12 +287,10 @@ function invalid(message: string): never {
286
287
  throw new DecodeError('sync.invalid_request', message);
287
288
  }
288
289
 
289
- function requireJsonDocument(raw: string, what: string): string {
290
- try {
291
- JSON.parse(raw);
292
- } catch {
293
- invalid(`${what} is not a valid JSON document`);
294
- }
290
+ /** A `json` field: a valid JSON document (Conventions), kept as raw text. */
291
+ function readJson(r: ByteReader, what: string): string {
292
+ const raw = r.str();
293
+ parseJsonDocument(raw, what);
295
294
  return raw;
296
295
  }
297
296
 
@@ -320,20 +319,16 @@ function validateRoundFailureCode(
320
319
  // Frame payload decoders (each consumes exactly the frame payload)
321
320
  // ---------------------------------------------------------------------------
322
321
 
323
- function decodeReqHeader(r: ByteReader, wireVersion: number): ReqHeaderFrame {
322
+ function decodeReqHeader(r: ByteReader): ReqHeaderFrame {
324
323
  const clientId = r.str();
325
324
  if (clientId.length === 0) invalid('REQ_HEADER.clientId must be non-empty');
326
325
  const schemaVersion = r.i32();
327
326
  if (schemaVersion < 1) invalid('REQ_HEADER.schemaVersion must be >= 1');
328
- const logEpoch =
329
- wireVersion >= 2
330
- ? r.opt(() => {
331
- const value = r.str();
332
- if (value.length === 0)
333
- invalid('REQ_HEADER.logEpoch must be non-empty');
334
- return value;
335
- })
336
- : undefined;
327
+ const logEpoch = r.opt(() => {
328
+ const value = r.str();
329
+ if (value.length === 0) invalid('REQ_HEADER.logEpoch must be non-empty');
330
+ return value;
331
+ });
337
332
  return definedObject({
338
333
  type: 'REQ_HEADER',
339
334
  clientId,
@@ -411,12 +406,10 @@ function decodeSubscription(r: ByteReader): SubscriptionFrame {
411
406
  const id = r.str();
412
407
  const table = r.str();
413
408
  const scopes = readStringListMap(r);
414
- const params = r.opt(() =>
415
- requireJsonDocument(r.str(), 'SUBSCRIPTION.params'),
416
- );
409
+ const params = r.opt(() => readJson(r, 'SUBSCRIPTION.params'));
417
410
  const cursor = r.i64();
418
411
  const bootstrapState = r.opt(() =>
419
- requireJsonDocument(r.str(), 'SUBSCRIPTION.bootstrapState'),
412
+ readJson(r, 'SUBSCRIPTION.bootstrapState'),
420
413
  );
421
414
  return definedObject({
422
415
  type: 'SUBSCRIPTION',
@@ -429,20 +422,20 @@ function decodeSubscription(r: ByteReader): SubscriptionFrame {
429
422
  });
430
423
  }
431
424
 
432
- function decodeRespHeader(r: ByteReader, wireVersion: number): RespHeaderFrame {
425
+ function decodeRespHeader(r: ByteReader): RespHeaderFrame {
433
426
  const requiredSchemaVersion = r.opt(() => r.i32());
434
427
  const latestSchemaVersion = r.opt(() => r.i32());
435
- const logEpoch = wireVersion >= 2 ? r.str() : undefined;
436
- if (logEpoch !== undefined && logEpoch.length === 0) {
428
+ const logEpoch = r.str();
429
+ if (logEpoch.length === 0) {
437
430
  invalid('RESP_HEADER.logEpoch must be non-empty');
438
431
  }
439
- const resetRequired = wireVersion >= 2 ? r.bool() : undefined;
432
+ const resetRequired = r.bool();
440
433
  return definedObject({
441
434
  type: 'RESP_HEADER',
442
435
  requiredSchemaVersion: requiredSchemaVersion,
443
436
  latestSchemaVersion: latestSchemaVersion,
444
- logEpoch: logEpoch,
445
- resetRequired: resetRequired,
437
+ logEpoch,
438
+ resetRequired,
446
439
  });
447
440
  }
448
441
 
@@ -523,9 +516,10 @@ function decodePushResultDetails(r: ByteReader): PushResultDetailsFrame {
523
516
  );
524
517
  }
525
518
  seen.add(opIndex);
519
+ const parsed = parseJsonDocument(r.str(), 'PUSH_RESULT_DETAILS.details');
526
520
  let details: RejectionDetails;
527
521
  try {
528
- details = normalizeRejectionDetails(JSON.parse(r.str()) as unknown);
522
+ details = normalizeRejectionDetails(parsed);
529
523
  } catch (error) {
530
524
  invalid(
531
525
  `invalid PUSH_RESULT_DETAILS.details: ${error instanceof Error ? error.message : String(error)}`,
@@ -647,9 +641,7 @@ function decodeSegmentInline(r: ByteReader): SegmentInlineFrame {
647
641
 
648
642
  function decodeSubEnd(r: ByteReader): SubEndFrame {
649
643
  const nextCursor = r.i64();
650
- const bootstrapState = r.opt(() =>
651
- requireJsonDocument(r.str(), 'SUB_END.bootstrapState'),
652
- );
644
+ const bootstrapState = r.opt(() => readJson(r, 'SUB_END.bootstrapState'));
653
645
  return definedObject({
654
646
  type: 'SUB_END',
655
647
  nextCursor,
@@ -663,7 +655,7 @@ function decodeErrorFrame(r: ByteReader): ErrorFrame {
663
655
  const category = r.str();
664
656
  const retryable = r.bool();
665
657
  const recommendedAction = r.str();
666
- const details = r.opt(() => requireJsonDocument(r.str(), 'ERROR.details'));
658
+ const details = r.opt(() => readJson(r, 'ERROR.details'));
667
659
  return definedObject({
668
660
  type: 'ERROR',
669
661
  code,
@@ -678,13 +670,12 @@ function decodeErrorFrame(r: ByteReader): ErrorFrame {
678
670
  function decodeRequestFrame(
679
671
  frameType: number,
680
672
  payload: Uint8Array,
681
- wireVersion: number,
682
673
  ): RequestFrame {
683
674
  const r = new ByteReader(payload);
684
675
  let frame: RequestFrame;
685
676
  switch (frameType) {
686
677
  case FrameType.REQ_HEADER:
687
- frame = decodeReqHeader(r, wireVersion);
678
+ frame = decodeReqHeader(r);
688
679
  break;
689
680
  case FrameType.ROUND_FAILURE:
690
681
  frame = decodeRoundFailure(r);
@@ -713,13 +704,12 @@ function decodeRequestFrame(
713
704
  function decodeResponseFrame(
714
705
  frameType: number,
715
706
  payload: Uint8Array,
716
- wireVersion: number,
717
707
  ): ResponseFrame {
718
708
  const r = new ByteReader(payload);
719
709
  let frame: ResponseFrame;
720
710
  switch (frameType) {
721
711
  case FrameType.RESP_HEADER:
722
- frame = decodeRespHeader(r, wireVersion);
712
+ frame = decodeRespHeader(r);
723
713
  break;
724
714
  case FrameType.LEASE:
725
715
  frame = decodeLease(r);
@@ -936,10 +926,7 @@ function encodeInvalid(message: string): never {
936
926
  throw new Error(message);
937
927
  }
938
928
 
939
- function encodeFrame(
940
- frame: RequestFrame | ResponseFrame,
941
- wireVersion: number,
942
- ): {
929
+ function encodeFrame(frame: RequestFrame | ResponseFrame): {
943
930
  frameType: number;
944
931
  payload: Uint8Array;
945
932
  } {
@@ -954,14 +941,10 @@ function encodeFrame(
954
941
  }
955
942
  w.str(frame.clientId);
956
943
  w.i32(frame.schemaVersion);
957
- if (wireVersion >= 2) {
958
- if (frame.logEpoch === '') {
959
- encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
960
- }
961
- w.opt(frame.logEpoch, (v) => w.str(v));
962
- } else if (frame.logEpoch !== undefined) {
963
- encodeInvalid('REQ_HEADER.logEpoch requires wireVersion 2');
944
+ if (frame.logEpoch === '') {
945
+ encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
964
946
  }
947
+ w.opt(frame.logEpoch, (v) => w.str(v));
965
948
  break;
966
949
  }
967
950
  case 'ROUND_FAILURE': {
@@ -1015,21 +998,11 @@ function encodeFrame(
1015
998
  case 'RESP_HEADER': {
1016
999
  w.opt(frame.requiredSchemaVersion, (v) => w.i32(v));
1017
1000
  w.opt(frame.latestSchemaVersion, (v) => w.i32(v));
1018
- if (wireVersion >= 2) {
1019
- if (frame.logEpoch === undefined || frame.logEpoch.length === 0) {
1020
- encodeInvalid('RESP_HEADER.logEpoch is required and non-empty');
1021
- }
1022
- if (frame.resetRequired === undefined) {
1023
- encodeInvalid('RESP_HEADER.resetRequired is required');
1024
- }
1025
- w.str(frame.logEpoch);
1026
- w.bool(frame.resetRequired);
1027
- } else if (
1028
- frame.logEpoch !== undefined ||
1029
- frame.resetRequired !== undefined
1030
- ) {
1031
- encodeInvalid('RESP_HEADER epoch fields require wireVersion 2');
1001
+ if (frame.logEpoch.length === 0) {
1002
+ encodeInvalid('RESP_HEADER.logEpoch must be non-empty');
1032
1003
  }
1004
+ w.str(frame.logEpoch);
1005
+ w.bool(frame.resetRequired);
1033
1006
  break;
1034
1007
  }
1035
1008
  case 'LEASE': {
@@ -1200,7 +1173,7 @@ export function encodeMessage(message: SyncMessage): Uint8Array {
1200
1173
  w.u8(message.msgKind === 'request' ? 0x01 : 0x02);
1201
1174
  w.u8(0x00);
1202
1175
  for (const frame of message.frames) {
1203
- const { frameType, payload } = encodeFrame(frame, message.wireVersion);
1176
+ const { frameType, payload } = encodeFrame(frame);
1204
1177
  w.u8(frameType);
1205
1178
  w.u32(payload.length);
1206
1179
  w.raw(payload);
@@ -1263,7 +1236,7 @@ export function decodeMessage(bytes: Uint8Array): SyncMessage {
1263
1236
  wireVersion,
1264
1237
  msgKind: 'request',
1265
1238
  frames: readFrames(r, (frameType, payload) =>
1266
- decodeRequestFrame(frameType, payload, wireVersion),
1239
+ decodeRequestFrame(frameType, payload),
1267
1240
  ),
1268
1241
  };
1269
1242
  validateRequestSequence(message.frames);
@@ -1273,7 +1246,7 @@ export function decodeMessage(bytes: Uint8Array): SyncMessage {
1273
1246
  wireVersion,
1274
1247
  msgKind: 'response',
1275
1248
  frames: readFrames(r, (frameType, payload) =>
1276
- decodeResponseFrame(frameType, payload, wireVersion),
1249
+ decodeResponseFrame(frameType, payload),
1277
1250
  ),
1278
1251
  };
1279
1252
  validateResponseSequence(message.frames);
package/src/realtime.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * `known: false` for unrecognized event names instead of failing.
10
10
  */
11
11
  import { DecodeError } from './errors';
12
+ import { parseJsonDocument } from './json-document';
12
13
 
13
14
  /** §8.7 channel tag: a standalone SSP2 response message — a delta. */
14
15
  export const REALTIME_TAG_DELTA = 0x00;
@@ -55,6 +56,9 @@ export type PresenceKind = 'join' | 'update' | 'leave';
55
56
 
56
57
  const PRESENCE_KINDS: readonly PresenceKind[] = ['join', 'update', 'leave'];
57
58
 
59
+ /** §8.6.2 publisher-directed presence rejection code — a closed set of two. */
60
+ export type PresenceErrorCode = 'presence.too_large' | 'presence.forbidden';
61
+
58
62
  /**
59
63
  * Server → client presence fanout (§8.6.2): a scope-mate's presence for a
60
64
  * key the receiver holds changed. `doc` is the peer's document for
@@ -70,7 +74,7 @@ export interface RealtimePresenceEvent {
70
74
  actorId?: string;
71
75
  clientId?: string;
72
76
  doc?: Record<string, unknown> | null;
73
- error?: string;
77
+ error?: PresenceErrorCode;
74
78
  timestamp?: number;
75
79
  };
76
80
  }
@@ -87,15 +91,6 @@ export interface RealtimeAck {
87
91
  cursor: number;
88
92
  }
89
93
 
90
- /** Client → server presence publish/leave (§8.6.2). `doc: null` = leave. */
91
- export interface RealtimePresencePublish {
92
- event: 'presence';
93
- data: {
94
- scopeKey: string;
95
- doc: Record<string, unknown> | null;
96
- };
97
- }
98
-
99
94
  export type ParsedRealtimeEvent =
100
95
  | { known: true; event: RealtimeServerEvent }
101
96
  | { known: false; eventName: string };
@@ -176,13 +171,10 @@ const WAKE_REASONS: readonly WakeReason[] = [
176
171
  ];
177
172
 
178
173
  export function parseRealtimeServerEvent(text: string): ParsedRealtimeEvent {
179
- let value: unknown;
180
- try {
181
- value = JSON.parse(text);
182
- } catch {
183
- malformed('not valid JSON');
184
- }
185
- const root = requireObject(value, 'control message');
174
+ const root = requireObject(
175
+ parseJsonDocument(text, 'control message'),
176
+ 'control message',
177
+ );
186
178
  const event = requireString(root.event, 'event');
187
179
  if (event === 'hello') {
188
180
  const data = requireObject(root.data, 'hello.data');
@@ -311,33 +303,3 @@ function requirePresenceDocObject(value: unknown): void {
311
303
  malformed('presence.data.doc must be a JSON object');
312
304
  }
313
305
  }
314
-
315
- /**
316
- * Parse a client → server presence publish/leave (§8.6.2). Used by the
317
- * server to validate inbound `presence` control messages. A malformed
318
- * message (missing/empty `scopeKey`, or a `doc` that is neither an object
319
- * nor `null`) throws a `DecodeError` — a known event with wrong-shape data
320
- * is never a tolerated variant (§8.1).
321
- */
322
- export function parseRealtimePresencePublish(
323
- text: string,
324
- ): RealtimePresencePublish {
325
- let value: unknown;
326
- try {
327
- value = JSON.parse(text);
328
- } catch {
329
- malformed('not valid JSON');
330
- }
331
- const root = requireObject(value, 'control message');
332
- if (root.event !== 'presence') {
333
- malformed('not a presence control message');
334
- }
335
- const data = requireObject(root.data, 'presence.data');
336
- const scopeKey = validatePresenceScopeKey(data.scopeKey);
337
- const doc = data.doc;
338
- if (doc !== null) requirePresenceDocObject(doc);
339
- return {
340
- event: 'presence',
341
- data: { scopeKey, doc: doc as Record<string, unknown> | null },
342
- };
343
- }
package/src/row-codec.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { parseBlobRef } from './blob-ref';
16
16
  import { ByteReader, ByteWriter } from './bytes';
17
17
  import { DecodeError, EncodeError } from './errors';
18
+ import { parseJsonDocument } from './json-document';
18
19
 
19
20
  export type ColumnType =
20
21
  | 'string'
@@ -148,9 +149,10 @@ function readValue(reader: ByteReader, column: RowColumn): RowValue {
148
149
  case 'string':
149
150
  return reader.str();
150
151
  case 'json': {
151
- // Conventions `json` MUST, applied at row-codec decode (SPEC.md §2.4
152
- // tag 5): the value must parse as a JSON document; the raw string is
153
- // preserved verbatim for round-trip fidelity.
152
+ // SPEC.md §2.4 tag 5: the value must parse as a JSON document; the raw
153
+ // string is preserved verbatim for round-trip fidelity. Full rows stay
154
+ // this permissive so stored rows keep decoding; `decodeSparseRow`
155
+ // applies the JSON validity rule on top.
154
156
  const raw = reader.str();
155
157
  try {
156
158
  JSON.parse(raw);
@@ -164,8 +166,8 @@ function readValue(reader: ByteReader, column: RowColumn): RowValue {
164
166
  }
165
167
  case 'blob_ref': {
166
168
  // §2.4 tag 7: the value is a canonical BlobRef JSON document
167
- // (§5.9.1). Validated at decode, same class as tag-5 json; the raw
168
- // string is preserved verbatim for re-encoding.
169
+ // (§5.9.1), checked on every decode path; the raw string is preserved
170
+ // verbatim for re-encoding.
169
171
  const raw = reader.str();
170
172
  parseBlobRef(raw);
171
173
  return raw;
@@ -262,7 +264,10 @@ export function encodeSparseRow(
262
264
  *
263
265
  * Decode checks, in order (each a decode error, RFC §6.1): a set presence
264
266
  * padding bit, an absent primary-key column, a set null-bitmap padding bit
265
- * (a null for an absent column), a null bit for a non-nullable column.
267
+ * (a null for an absent column), a null bit for a non-nullable column. Each
268
+ * present value must also be a push admission value (SPEC.md §2.4): no
269
+ * U+0000 in a `string`, a `json` value under the JSON validity rule, a finite
270
+ * `float`, a canonical `blob_ref`.
266
271
  */
267
272
  export function decodeSparseRow(
268
273
  columns: readonly RowColumn[],
@@ -318,7 +323,34 @@ export function decodeSparseRow(
318
323
  const isNull = bitmapBit(nulls, present);
319
324
  present++;
320
325
  if (!isNull) {
321
- values.push(readValue(reader, column));
326
+ const value = readValue(reader, column);
327
+ // Push admission values (SPEC.md §2.4): one value set every storage
328
+ // backend and core accepts. `blob_ref` canonicality is already checked
329
+ // by `readValue` on every path.
330
+ if (
331
+ column.type === 'string' &&
332
+ typeof value === 'string' &&
333
+ value.includes('\u0000')
334
+ ) {
335
+ throw new DecodeError(
336
+ 'sync.invalid_request',
337
+ `string column ${column.name} holds U+0000`,
338
+ );
339
+ }
340
+ if (column.type === 'json' && typeof value === 'string') {
341
+ parseJsonDocument(value, `json column ${column.name}`);
342
+ }
343
+ if (
344
+ column.type === 'float' &&
345
+ typeof value === 'number' &&
346
+ !Number.isFinite(value)
347
+ ) {
348
+ throw new DecodeError(
349
+ 'sync.invalid_request',
350
+ `float column ${column.name} holds NaN or an infinity`,
351
+ );
352
+ }
353
+ values.push(value);
322
354
  continue;
323
355
  }
324
356
  if (!column.nullable) {