@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/dist/blob-ref.d.ts +4 -4
- package/dist/blob-ref.js +21 -38
- package/dist/canonical-json.d.ts +20 -9
- package/dist/canonical-json.js +108 -6
- package/dist/constants.d.ts +5 -5
- package/dist/constants.js +5 -5
- package/dist/crypto.js +2 -1
- package/dist/frames.d.ts +6 -3
- package/dist/frames.js +10 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/json-document.d.ts +11 -0
- package/dist/json-document.js +50 -0
- package/dist/message.d.ts +59 -18
- package/dist/message.js +311 -176
- package/dist/realtime.d.ts +3 -17
- package/dist/realtime.js +2 -37
- package/dist/render.js +34 -11
- package/dist/row-codec.d.ts +4 -1
- package/dist/row-codec.js +29 -7
- package/package.json +1 -1
- package/src/blob-ref.ts +27 -47
- package/src/canonical-json.ts +135 -6
- package/src/constants.ts +5 -5
- package/src/crypto.ts +2 -1
- package/src/frames.ts +10 -4
- package/src/index.ts +1 -0
- package/src/json-document.ts +62 -0
- package/src/message.ts +424 -217
- package/src/realtime.ts +9 -47
- package/src/render.ts +34 -11
- package/src/row-codec.ts +39 -7
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?:
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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 '
|
|
169
|
+
case 'UNIT':
|
|
170
|
+
return { type: 'UNIT', scopes: renderStringMap(frame.scopes) };
|
|
171
|
+
case 'PROGRESS':
|
|
160
172
|
return {
|
|
161
|
-
type: '
|
|
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
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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)
|
|
168
|
-
//
|
|
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
|
-
|
|
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) {
|