@syncular/core 0.57.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.
- package/dist/blob-ref.d.ts +4 -4
- package/dist/blob-ref.js +21 -38
- package/dist/crypto.js +2 -1
- 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 +5 -5
- package/dist/message.js +39 -56
- package/dist/realtime.d.ts +3 -17
- package/dist/realtime.js +2 -37
- 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/crypto.ts +2 -1
- package/src/index.ts +1 -0
- package/src/json-document.ts +62 -0
- package/src/message.ts +41 -68
- package/src/realtime.ts +9 -47
- package/src/row-codec.ts +39 -7
package/dist/blob-ref.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
62
|
-
logEpoch
|
|
63
|
-
/**
|
|
64
|
-
resetRequired
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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 =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(() =>
|
|
138
|
+
const params = r.opt(() => readJson(r, 'SUBSCRIPTION.params'));
|
|
143
139
|
const cursor = r.i64();
|
|
144
|
-
const bootstrapState = r.opt(() =>
|
|
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
|
|
151
|
+
function decodeRespHeader(r) {
|
|
156
152
|
const requiredSchemaVersion = r.opt(() => r.i32());
|
|
157
153
|
const latestSchemaVersion = r.opt(() => r.i32());
|
|
158
|
-
const logEpoch =
|
|
159
|
-
if (logEpoch
|
|
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 =
|
|
158
|
+
const resetRequired = r.bool();
|
|
163
159
|
return definedObject({
|
|
164
160
|
type: 'RESP_HEADER',
|
|
165
161
|
requiredSchemaVersion: requiredSchemaVersion,
|
|
166
162
|
latestSchemaVersion: latestSchemaVersion,
|
|
167
|
-
logEpoch
|
|
168
|
-
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(
|
|
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(() =>
|
|
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(() =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
657
|
-
|
|
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 (
|
|
719
|
-
|
|
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
|
|
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
|
|
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
|
|
942
|
+
frames: readFrames(r, (frameType, payload) => decodeResponseFrame(frameType, payload)),
|
|
960
943
|
};
|
|
961
944
|
validateResponseSequence(message.frames);
|
|
962
945
|
return message;
|
package/dist/realtime.d.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
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/row-codec.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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)
|
|
100
|
-
//
|
|
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
|
-
|
|
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
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
114
|
-
logEpoch
|
|
115
|
-
/**
|
|
116
|
-
resetRequired
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
436
|
-
if (logEpoch
|
|
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 =
|
|
432
|
+
const resetRequired = r.bool();
|
|
440
433
|
return definedObject({
|
|
441
434
|
type: 'RESP_HEADER',
|
|
442
435
|
requiredSchemaVersion: requiredSchemaVersion,
|
|
443
436
|
latestSchemaVersion: latestSchemaVersion,
|
|
444
|
-
logEpoch
|
|
445
|
-
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(
|
|
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(() =>
|
|
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
|
|
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
|
|
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 (
|
|
958
|
-
|
|
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 (
|
|
1019
|
-
|
|
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
|
|
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
|
|
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
|
|
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?:
|
|
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/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) {
|