@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/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/render.js
CHANGED
|
@@ -42,6 +42,13 @@ function renderScopeLists(scopes) {
|
|
|
42
42
|
function renderStringMap(map) {
|
|
43
43
|
return { ...map };
|
|
44
44
|
}
|
|
45
|
+
/** Digest fields render as lowercase hex (Conventions: hashes are hex). */
|
|
46
|
+
function renderDigest(digest) {
|
|
47
|
+
let hex = '';
|
|
48
|
+
for (const byte of digest)
|
|
49
|
+
hex += byte.toString(16).padStart(2, '0');
|
|
50
|
+
return hex;
|
|
51
|
+
}
|
|
45
52
|
function renderOperation(operation) {
|
|
46
53
|
return {
|
|
47
54
|
table: operation.table,
|
|
@@ -83,9 +90,7 @@ function renderChange(change) {
|
|
|
83
90
|
tableIndex: change.tableIndex,
|
|
84
91
|
rowId: change.rowId,
|
|
85
92
|
op: change.op,
|
|
86
|
-
|
|
87
|
-
? { rowVersion: change.rowVersion }
|
|
88
|
-
: {}),
|
|
93
|
+
rowVersion: change.rowVersion,
|
|
89
94
|
scopes: renderStringMap(change.scopes),
|
|
90
95
|
...(change.row !== undefined ? { row: bytesToBase64(change.row) } : {}),
|
|
91
96
|
};
|
|
@@ -98,6 +103,7 @@ function renderFrame(frame) {
|
|
|
98
103
|
clientId: frame.clientId,
|
|
99
104
|
schemaVersion: frame.schemaVersion,
|
|
100
105
|
...(frame.logEpoch !== undefined ? { logEpoch: frame.logEpoch } : {}),
|
|
106
|
+
...(frame.outboxId !== undefined ? { outboxId: frame.outboxId } : {}),
|
|
101
107
|
};
|
|
102
108
|
case 'ROUND_FAILURE':
|
|
103
109
|
return { type: 'ROUND_FAILURE', code: frame.code };
|
|
@@ -105,6 +111,7 @@ function renderFrame(frame) {
|
|
|
105
111
|
return {
|
|
106
112
|
type: 'PUSH_COMMIT',
|
|
107
113
|
clientCommitId: frame.clientCommitId,
|
|
114
|
+
clientSeq: frame.clientSeq,
|
|
108
115
|
operations: frame.operations.map(renderOperation),
|
|
109
116
|
};
|
|
110
117
|
case 'PULL_HEADER':
|
|
@@ -114,16 +121,16 @@ function renderFrame(frame) {
|
|
|
114
121
|
limitSnapshotRows: frame.limitSnapshotRows,
|
|
115
122
|
maxSnapshotPages: frame.maxSnapshotPages,
|
|
116
123
|
accept: frame.accept,
|
|
124
|
+
cursor: frame.cursor,
|
|
125
|
+
registrationDigest: renderDigest(frame.registrationDigest),
|
|
126
|
+
echoDigest: renderDigest(frame.echoDigest),
|
|
117
127
|
};
|
|
118
|
-
case '
|
|
128
|
+
case 'UNIT':
|
|
129
|
+
return { type: 'UNIT', scopes: renderStringMap(frame.scopes) };
|
|
130
|
+
case 'PROGRESS':
|
|
119
131
|
return {
|
|
120
|
-
type: '
|
|
132
|
+
type: 'PROGRESS',
|
|
121
133
|
id: frame.id,
|
|
122
|
-
table: frame.table,
|
|
123
|
-
scopes: renderScopeLists(frame.scopes),
|
|
124
|
-
...(frame.params !== undefined
|
|
125
|
-
? { params: parseJson(frame.params) }
|
|
126
|
-
: {}),
|
|
127
134
|
cursor: frame.cursor,
|
|
128
135
|
...(frame.bootstrapState !== undefined
|
|
129
136
|
? { bootstrapState: parseJson(frame.bootstrapState) }
|
|
@@ -187,8 +194,24 @@ function renderFrame(frame) {
|
|
|
187
194
|
id: frame.id,
|
|
188
195
|
status: frame.status,
|
|
189
196
|
reasonCode: frame.reasonCode,
|
|
190
|
-
effectiveScopes: renderScopeLists(frame.effectiveScopes),
|
|
191
197
|
bootstrap: frame.bootstrap,
|
|
198
|
+
fenced: frame.fenced.map((record) => ({
|
|
199
|
+
table: record.table,
|
|
200
|
+
status: record.status,
|
|
201
|
+
scopes: renderScopeLists(record.scopes),
|
|
202
|
+
})),
|
|
203
|
+
};
|
|
204
|
+
case 'LIVE_START':
|
|
205
|
+
return {
|
|
206
|
+
type: 'LIVE_START',
|
|
207
|
+
status: frame.status,
|
|
208
|
+
reasonCode: frame.reasonCode,
|
|
209
|
+
};
|
|
210
|
+
case 'LIVE_END':
|
|
211
|
+
return {
|
|
212
|
+
type: 'LIVE_END',
|
|
213
|
+
throughSeq: frame.throughSeq,
|
|
214
|
+
echoDigest: renderDigest(frame.echoDigest),
|
|
192
215
|
};
|
|
193
216
|
case 'COMMIT':
|
|
194
217
|
return {
|
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/canonical-json.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical JSON for digests (SPEC.md §11.2)
|
|
3
|
-
* implementations: UTF-8, no insignificant whitespace,
|
|
4
|
-
* by code-unit, scope value lists sorted and
|
|
2
|
+
* Canonical JSON for digests (SPEC.md §11.2) and the digests over it —
|
|
3
|
+
* contractual across implementations: UTF-8, no insignificant whitespace,
|
|
4
|
+
* object keys sorted by code-unit, scope value lists sorted and
|
|
5
|
+
* deduplicated, every other array in its stated order.
|
|
5
6
|
*
|
|
6
|
-
* Used for the
|
|
7
|
-
* (§
|
|
8
|
-
* canonical text.
|
|
7
|
+
* Used for the unit id and registration digest (§4.1), the echo digest
|
|
8
|
+
* (§4.4), the scope digest (§3.5) and the `X-Syncular-Scopes` header (§5.5).
|
|
9
9
|
*/
|
|
10
|
+
import { utf8Encode } from './bytes';
|
|
11
|
+
import type { FencedRecord } from './message';
|
|
12
|
+
|
|
13
|
+
/** A unit's scope map: one value per requested variable (§4.1). */
|
|
14
|
+
export type UnitScopes = Readonly<Record<string, string>>;
|
|
15
|
+
|
|
10
16
|
export function canonicalScopeJson(
|
|
11
17
|
scopes: Readonly<Record<string, readonly string[]>>,
|
|
12
18
|
): string {
|
|
@@ -18,3 +24,126 @@ export function canonicalScopeJson(
|
|
|
18
24
|
});
|
|
19
25
|
return `{${parts.join(',')}}`;
|
|
20
26
|
}
|
|
27
|
+
|
|
28
|
+
/** §4.1: the canonical JSON of a unit's scope map. */
|
|
29
|
+
export function canonicalUnitJson(scopes: UnitScopes): string {
|
|
30
|
+
const keys = Object.keys(scopes).sort();
|
|
31
|
+
const parts = keys.map(
|
|
32
|
+
(key) => `${JSON.stringify(key)}:${JSON.stringify(scopes[key])}`,
|
|
33
|
+
);
|
|
34
|
+
return `{${parts.join(',')}}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** §4.1: lowercase hex of the first 16 bytes of SHA-256 over the canonical
|
|
38
|
+
* JSON of the unit's scope map. */
|
|
39
|
+
export function unitId(scopes: UnitScopes): string {
|
|
40
|
+
let hex = '';
|
|
41
|
+
for (const byte of sha256(utf8Encode(canonicalUnitJson(scopes))).subarray(
|
|
42
|
+
0,
|
|
43
|
+
16,
|
|
44
|
+
)) {
|
|
45
|
+
hex += byte.toString(16).padStart(2, '0');
|
|
46
|
+
}
|
|
47
|
+
return hex;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** §4.1: SHA-256 over the canonical JSON array of the units' scope maps in
|
|
51
|
+
* registration order, as 32 raw bytes. */
|
|
52
|
+
export function registrationDigest(units: readonly UnitScopes[]): Uint8Array {
|
|
53
|
+
return sha256(utf8Encode(`[${units.map(canonicalUnitJson).join(',')}]`));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One unit of the effective list as the echo digest sees it (§4.4). */
|
|
57
|
+
export interface UnitEcho {
|
|
58
|
+
id: string;
|
|
59
|
+
status: 'active' | 'revoked';
|
|
60
|
+
fenced: readonly FencedRecord[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** §4.4: SHA-256 over the canonical JSON array, in registration order, of
|
|
64
|
+
* `{"fenced":[…],"id":…,"status":…}` per unit, as 32 raw bytes. */
|
|
65
|
+
export function echoDigest(units: readonly UnitEcho[]): Uint8Array {
|
|
66
|
+
const entries = units.map((unit) => {
|
|
67
|
+
const fenced = unit.fenced.map(
|
|
68
|
+
(record) =>
|
|
69
|
+
`{"scopes":${canonicalScopeJson(record.scopes)},"status":${JSON.stringify(record.status)},"table":${JSON.stringify(record.table)}}`,
|
|
70
|
+
);
|
|
71
|
+
return `{"fenced":[${fenced.join(',')}],"id":${JSON.stringify(unit.id)},"status":${JSON.stringify(unit.status)}}`;
|
|
72
|
+
});
|
|
73
|
+
return sha256(utf8Encode(`[${entries.join(',')}]`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// FIPS 180-4 SHA-256. Synchronous so the unit id is a plain function of the
|
|
77
|
+
// scope map on every runtime (WebCrypto digests are async only).
|
|
78
|
+
const K = new Uint32Array([
|
|
79
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
|
|
80
|
+
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
|
81
|
+
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
|
82
|
+
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
83
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
|
84
|
+
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
85
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
|
|
86
|
+
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
87
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
|
88
|
+
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
|
89
|
+
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
function sha256(data: Uint8Array): Uint8Array {
|
|
93
|
+
const padded = new Uint8Array(((data.length + 72) >>> 6) << 6);
|
|
94
|
+
padded.set(data);
|
|
95
|
+
padded[data.length] = 0x80;
|
|
96
|
+
const view = new DataView(padded.buffer);
|
|
97
|
+
view.setUint32(padded.length - 8, Math.floor(data.length / 0x20000000));
|
|
98
|
+
view.setUint32(padded.length - 4, (data.length * 8) >>> 0);
|
|
99
|
+
const h: [number, number, number, number, number, number, number, number] = [
|
|
100
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
|
|
101
|
+
0x1f83d9ab, 0x5be0cd19,
|
|
102
|
+
];
|
|
103
|
+
const w = new Uint32Array(64);
|
|
104
|
+
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
|
105
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
106
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
|
|
107
|
+
for (let i = 16; i < 64; i++) {
|
|
108
|
+
const a = w[i - 15] as number;
|
|
109
|
+
const b = w[i - 2] as number;
|
|
110
|
+
w[i] =
|
|
111
|
+
(rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10)) +
|
|
112
|
+
(w[i - 7] as number) +
|
|
113
|
+
(rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3)) +
|
|
114
|
+
(w[i - 16] as number);
|
|
115
|
+
}
|
|
116
|
+
let [a, b, c, d, e, f, g, hh] = h;
|
|
117
|
+
for (let i = 0; i < 64; i++) {
|
|
118
|
+
const t1 =
|
|
119
|
+
hh +
|
|
120
|
+
(rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) +
|
|
121
|
+
((e & f) ^ (~e & g)) +
|
|
122
|
+
(K[i] as number) +
|
|
123
|
+
(w[i] as number);
|
|
124
|
+
const t2 =
|
|
125
|
+
(rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) +
|
|
126
|
+
((a & b) ^ (a & c) ^ (b & c));
|
|
127
|
+
hh = g;
|
|
128
|
+
g = f;
|
|
129
|
+
f = e;
|
|
130
|
+
e = (d + t1) | 0;
|
|
131
|
+
d = c;
|
|
132
|
+
c = b;
|
|
133
|
+
b = a;
|
|
134
|
+
a = (t1 + t2) | 0;
|
|
135
|
+
}
|
|
136
|
+
h[0] = (h[0] + a) | 0;
|
|
137
|
+
h[1] = (h[1] + b) | 0;
|
|
138
|
+
h[2] = (h[2] + c) | 0;
|
|
139
|
+
h[3] = (h[3] + d) | 0;
|
|
140
|
+
h[4] = (h[4] + e) | 0;
|
|
141
|
+
h[5] = (h[5] + f) | 0;
|
|
142
|
+
h[6] = (h[6] + g) | 0;
|
|
143
|
+
h[7] = (h[7] + hh) | 0;
|
|
144
|
+
}
|
|
145
|
+
const out = new Uint8Array(32);
|
|
146
|
+
const outView = new DataView(out.buffer);
|
|
147
|
+
h.forEach((word, i) => outView.setUint32(i * 4, word));
|
|
148
|
+
return out;
|
|
149
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/** Protocol constants (SPEC.md §1.2, §9 are normative). */
|
|
2
2
|
|
|
3
3
|
/** Latest SSP2 envelope wire version emitted by current clients. */
|
|
4
|
-
export const PROTOCOL_WIRE_VERSION =
|
|
4
|
+
export const PROTOCOL_WIRE_VERSION = 4;
|
|
5
5
|
|
|
6
|
-
/** Oldest SSP2 envelope wire version accepted. Wire version
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
export const MINIMUM_PROTOCOL_WIRE_VERSION =
|
|
6
|
+
/** Oldest SSP2 envelope wire version accepted. Wire version 4 replaced
|
|
7
|
+
* per-table subscriptions with units and the replica cursor (§4); earlier
|
|
8
|
+
* versions are rejected as unknown (§9). */
|
|
9
|
+
export const MINIMUM_PROTOCOL_WIRE_VERSION = 4;
|
|
10
10
|
|
|
11
11
|
/** Wire versions implemented by the reference codec, oldest first. */
|
|
12
12
|
export const SUPPORTED_PROTOCOL_WIRE_VERSIONS: readonly number[] =
|
package/src/crypto.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* is the symmetric core and the envelope both sides agree on byte-for-byte.
|
|
14
14
|
*/
|
|
15
15
|
import { ByteReader, ByteWriter } from './bytes';
|
|
16
|
+
import { parseJsonDocument } from './json-document';
|
|
16
17
|
|
|
17
18
|
/** The pre-flip application type of an encrypted column (SPEC.md §5.11). */
|
|
18
19
|
export type DeclaredType =
|
|
@@ -150,7 +151,7 @@ export function deserializePlain(
|
|
|
150
151
|
}
|
|
151
152
|
if (declaredType === 'json' || declaredType === 'blob_ref') {
|
|
152
153
|
try {
|
|
153
|
-
|
|
154
|
+
parseJsonDocument(text, `decrypted ${declaredType} value`);
|
|
154
155
|
} catch {
|
|
155
156
|
throw new DecryptError(
|
|
156
157
|
`decrypted ${declaredType} value does not parse`,
|
package/src/frames.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
/** Frame type registry for wire
|
|
1
|
+
/** Frame type registry for wire version 4 (SPEC.md §1.2). */
|
|
2
2
|
export const FrameType = {
|
|
3
3
|
END: 0x00,
|
|
4
4
|
REQ_HEADER: 0x01,
|
|
5
5
|
PUSH_COMMIT: 0x02,
|
|
6
6
|
PULL_HEADER: 0x03,
|
|
7
|
-
|
|
7
|
+
UNIT: 0x04,
|
|
8
8
|
ROUND_FAILURE: 0x05,
|
|
9
|
+
PROGRESS: 0x06,
|
|
9
10
|
RESP_HEADER: 0x10,
|
|
10
11
|
PUSH_RESULT: 0x11,
|
|
11
12
|
SUB_START: 0x12,
|
|
@@ -15,6 +16,8 @@ export const FrameType = {
|
|
|
15
16
|
SUB_END: 0x16,
|
|
16
17
|
LEASE: 0x19,
|
|
17
18
|
PUSH_RESULT_DETAILS: 0x1b,
|
|
19
|
+
LIVE_START: 0x1c,
|
|
20
|
+
LIVE_END: 0x1d,
|
|
18
21
|
ERROR: 0x1f,
|
|
19
22
|
} as const;
|
|
20
23
|
|
|
@@ -25,8 +28,9 @@ export const REQUEST_FRAME_TYPES: ReadonlySet<number> = new Set([
|
|
|
25
28
|
FrameType.REQ_HEADER,
|
|
26
29
|
FrameType.PUSH_COMMIT,
|
|
27
30
|
FrameType.PULL_HEADER,
|
|
28
|
-
FrameType.
|
|
31
|
+
FrameType.UNIT,
|
|
29
32
|
FrameType.ROUND_FAILURE,
|
|
33
|
+
FrameType.PROGRESS,
|
|
30
34
|
]);
|
|
31
35
|
|
|
32
36
|
/** Known response frame types (excluding END). */
|
|
@@ -40,10 +44,12 @@ export const RESPONSE_FRAME_TYPES: ReadonlySet<number> = new Set([
|
|
|
40
44
|
FrameType.SUB_END,
|
|
41
45
|
FrameType.LEASE,
|
|
42
46
|
FrameType.PUSH_RESULT_DETAILS,
|
|
47
|
+
FrameType.LIVE_START,
|
|
48
|
+
FrameType.LIVE_END,
|
|
43
49
|
FrameType.ERROR,
|
|
44
50
|
]);
|
|
45
51
|
|
|
46
|
-
/** Every frame type with a defined layout in wire
|
|
52
|
+
/** Every frame type with a defined layout in wire version 4. */
|
|
47
53
|
export const KNOWN_FRAME_TYPES: ReadonlySet<number> = new Set([
|
|
48
54
|
FrameType.END,
|
|
49
55
|
...REQUEST_FRAME_TYPES,
|
package/src/index.ts
CHANGED