@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.
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/render.ts CHANGED
@@ -80,6 +80,13 @@ function renderStringMap(map: Readonly<Record<string, string>>): JsonValue {
80
80
  return { ...map };
81
81
  }
82
82
 
83
+ /** Digest fields render as lowercase hex (Conventions: hashes are hex). */
84
+ function renderDigest(digest: Uint8Array): string {
85
+ let hex = '';
86
+ for (const byte of digest) hex += byte.toString(16).padStart(2, '0');
87
+ return hex;
88
+ }
89
+
83
90
  function renderOperation(operation: PushOperation): JsonValue {
84
91
  return {
85
92
  table: operation.table,
@@ -123,9 +130,7 @@ function renderChange(change: CommitChange): JsonValue {
123
130
  tableIndex: change.tableIndex,
124
131
  rowId: change.rowId,
125
132
  op: change.op,
126
- ...(change.rowVersion !== undefined
127
- ? { rowVersion: change.rowVersion }
128
- : {}),
133
+ rowVersion: change.rowVersion,
129
134
  scopes: renderStringMap(change.scopes),
130
135
  ...(change.row !== undefined ? { row: bytesToBase64(change.row) } : {}),
131
136
  };
@@ -139,6 +144,7 @@ function renderFrame(frame: RequestFrame | ResponseFrame): JsonValue {
139
144
  clientId: frame.clientId,
140
145
  schemaVersion: frame.schemaVersion,
141
146
  ...(frame.logEpoch !== undefined ? { logEpoch: frame.logEpoch } : {}),
147
+ ...(frame.outboxId !== undefined ? { outboxId: frame.outboxId } : {}),
142
148
  };
143
149
  case 'ROUND_FAILURE':
144
150
  return { type: 'ROUND_FAILURE', code: frame.code };
@@ -146,6 +152,7 @@ function renderFrame(frame: RequestFrame | ResponseFrame): JsonValue {
146
152
  return {
147
153
  type: 'PUSH_COMMIT',
148
154
  clientCommitId: frame.clientCommitId,
155
+ clientSeq: frame.clientSeq,
149
156
  operations: frame.operations.map(renderOperation),
150
157
  };
151
158
  case 'PULL_HEADER':
@@ -155,16 +162,16 @@ function renderFrame(frame: RequestFrame | ResponseFrame): JsonValue {
155
162
  limitSnapshotRows: frame.limitSnapshotRows,
156
163
  maxSnapshotPages: frame.maxSnapshotPages,
157
164
  accept: frame.accept,
165
+ cursor: frame.cursor,
166
+ registrationDigest: renderDigest(frame.registrationDigest),
167
+ echoDigest: renderDigest(frame.echoDigest),
158
168
  };
159
- case 'SUBSCRIPTION':
169
+ case 'UNIT':
170
+ return { type: 'UNIT', scopes: renderStringMap(frame.scopes) };
171
+ case 'PROGRESS':
160
172
  return {
161
- type: 'SUBSCRIPTION',
173
+ type: 'PROGRESS',
162
174
  id: frame.id,
163
- table: frame.table,
164
- scopes: renderScopeLists(frame.scopes),
165
- ...(frame.params !== undefined
166
- ? { params: parseJson(frame.params) }
167
- : {}),
168
175
  cursor: frame.cursor,
169
176
  ...(frame.bootstrapState !== undefined
170
177
  ? { bootstrapState: parseJson(frame.bootstrapState) }
@@ -228,8 +235,24 @@ function renderFrame(frame: RequestFrame | ResponseFrame): JsonValue {
228
235
  id: frame.id,
229
236
  status: frame.status,
230
237
  reasonCode: frame.reasonCode,
231
- effectiveScopes: renderScopeLists(frame.effectiveScopes),
232
238
  bootstrap: frame.bootstrap,
239
+ fenced: frame.fenced.map((record) => ({
240
+ table: record.table,
241
+ status: record.status,
242
+ scopes: renderScopeLists(record.scopes),
243
+ })),
244
+ };
245
+ case 'LIVE_START':
246
+ return {
247
+ type: 'LIVE_START',
248
+ status: frame.status,
249
+ reasonCode: frame.reasonCode,
250
+ };
251
+ case 'LIVE_END':
252
+ return {
253
+ type: 'LIVE_END',
254
+ throughSeq: frame.throughSeq,
255
+ echoDigest: renderDigest(frame.echoDigest),
233
256
  };
234
257
  case 'COMMIT':
235
258
  return {
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) {