@syncular/core 0.1.3 → 0.2.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/README.md +5 -19
- package/dist/blob-ref.d.ts +21 -0
- package/dist/blob-ref.js +97 -0
- package/dist/bytes.d.ts +45 -0
- package/dist/bytes.js +242 -0
- package/dist/canonical-json.d.ts +10 -0
- package/dist/canonical-json.js +18 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.js +5 -0
- package/dist/crypto.d.ts +60 -0
- package/dist/crypto.js +251 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/frames.d.ts +24 -0
- package/dist/frames.js +42 -0
- package/dist/index.d.ts +18 -26
- package/dist/index.js +18 -43
- package/dist/message.d.ts +157 -0
- package/dist/message.js +786 -0
- package/dist/realtime.d.ts +92 -0
- package/dist/realtime.js +222 -0
- package/dist/render.d.ts +22 -0
- package/dist/render.js +273 -0
- package/dist/row-codec.d.ts +39 -0
- package/dist/row-codec.js +176 -0
- package/dist/segment.d.ts +18 -0
- package/dist/segment.js +105 -0
- package/dist/stream.d.ts +20 -0
- package/dist/stream.js +102 -0
- package/package.json +7 -59
- package/src/blob-ref.ts +135 -0
- package/src/bytes.ts +301 -0
- package/src/canonical-json.ts +20 -0
- package/src/constants.ts +7 -0
- package/src/crypto.ts +335 -0
- package/src/errors.ts +18 -0
- package/src/frames.ts +47 -0
- package/src/index.ts +18 -43
- package/src/message.ts +1024 -0
- package/src/realtime.ts +340 -0
- package/src/render.ts +325 -0
- package/src/row-codec.ts +265 -0
- package/src/segment.ts +150 -0
- package/src/stream.ts +128 -0
- package/dist/blobs.d.ts +0 -153
- package/dist/blobs.d.ts.map +0 -1
- package/dist/blobs.js +0 -47
- package/dist/blobs.js.map +0 -1
- package/dist/column-codecs.d.ts +0 -59
- package/dist/column-codecs.d.ts.map +0 -1
- package/dist/column-codecs.js +0 -159
- package/dist/column-codecs.js.map +0 -1
- package/dist/conflict.d.ts +0 -22
- package/dist/conflict.d.ts.map +0 -1
- package/dist/conflict.js +0 -81
- package/dist/conflict.js.map +0 -1
- package/dist/database.d.ts +0 -7
- package/dist/database.d.ts.map +0 -1
- package/dist/database.js +0 -14
- package/dist/database.js.map +0 -1
- package/dist/dialect.d.ts +0 -7
- package/dist/dialect.d.ts.map +0 -1
- package/dist/dialect.js +0 -2
- package/dist/dialect.js.map +0 -1
- package/dist/error-responses.d.ts +0 -393
- package/dist/error-responses.d.ts.map +0 -1
- package/dist/error-responses.js +0 -393
- package/dist/error-responses.js.map +0 -1
- package/dist/http/api-client.d.ts +0 -11
- package/dist/http/api-client.d.ts.map +0 -1
- package/dist/http/api-client.js +0 -30
- package/dist/http/api-client.js.map +0 -1
- package/dist/http/blob.d.ts +0 -6
- package/dist/http/blob.d.ts.map +0 -1
- package/dist/http/blob.js +0 -46
- package/dist/http/blob.js.map +0 -1
- package/dist/http/generated/api.d.ts +0 -5241
- package/dist/http/generated/api.d.ts.map +0 -1
- package/dist/http/generated/api.js +0 -6
- package/dist/http/generated/api.js.map +0 -1
- package/dist/http/index.d.ts +0 -18
- package/dist/http/index.d.ts.map +0 -1
- package/dist/http/index.js +0 -212
- package/dist/http/index.js.map +0 -1
- package/dist/http/shared.d.ts +0 -45
- package/dist/http/shared.d.ts.map +0 -1
- package/dist/http/shared.js +0 -122
- package/dist/http/shared.js.map +0 -1
- package/dist/http/transport-client.d.ts +0 -31
- package/dist/http/transport-client.d.ts.map +0 -1
- package/dist/http/transport-client.js +0 -193
- package/dist/http/transport-client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/kysely-column-codecs.d.ts +0 -31
- package/dist/kysely-column-codecs.d.ts.map +0 -1
- package/dist/kysely-column-codecs.js +0 -452
- package/dist/kysely-column-codecs.js.map +0 -1
- package/dist/logger.d.ts +0 -29
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -26
- package/dist/logger.js.map +0 -1
- package/dist/proxy/index.d.ts +0 -54
- package/dist/proxy/index.d.ts.map +0 -1
- package/dist/proxy/index.js +0 -7
- package/dist/proxy/index.js.map +0 -1
- package/dist/schemas/blobs.d.ts +0 -82
- package/dist/schemas/blobs.d.ts.map +0 -1
- package/dist/schemas/blobs.js +0 -64
- package/dist/schemas/blobs.js.map +0 -1
- package/dist/schemas/common.d.ts +0 -100
- package/dist/schemas/common.d.ts.map +0 -1
- package/dist/schemas/common.js +0 -64
- package/dist/schemas/common.js.map +0 -1
- package/dist/schemas/index.d.ts +0 -7
- package/dist/schemas/index.d.ts.map +0 -1
- package/dist/schemas/index.js +0 -7
- package/dist/schemas/index.js.map +0 -1
- package/dist/schemas/sync.d.ts +0 -1065
- package/dist/schemas/sync.d.ts.map +0 -1
- package/dist/schemas/sync.js +0 -361
- package/dist/schemas/sync.js.map +0 -1
- package/dist/scopes/index.d.ts +0 -113
- package/dist/scopes/index.d.ts.map +0 -1
- package/dist/scopes/index.js +0 -103
- package/dist/scopes/index.js.map +0 -1
- package/dist/sentry.d.ts +0 -31
- package/dist/sentry.d.ts.map +0 -1
- package/dist/sentry.js +0 -147
- package/dist/sentry.js.map +0 -1
- package/dist/snapshot-chunks.d.ts +0 -110
- package/dist/snapshot-chunks.d.ts.map +0 -1
- package/dist/snapshot-chunks.js +0 -682
- package/dist/snapshot-chunks.js.map +0 -1
- package/dist/sync-packs.d.ts +0 -33
- package/dist/sync-packs.d.ts.map +0 -1
- package/dist/sync-packs.js +0 -821
- package/dist/sync-packs.js.map +0 -1
- package/dist/telemetry.d.ts +0 -114
- package/dist/telemetry.d.ts.map +0 -1
- package/dist/telemetry.js +0 -114
- package/dist/telemetry.js.map +0 -1
- package/dist/transforms.d.ts +0 -146
- package/dist/transforms.d.ts.map +0 -1
- package/dist/transforms.js +0 -155
- package/dist/transforms.js.map +0 -1
- package/dist/types.d.ts +0 -210
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -20
- package/dist/types.js.map +0 -1
- package/dist/utils/bytes.d.ts +0 -4
- package/dist/utils/bytes.d.ts.map +0 -1
- package/dist/utils/bytes.js +0 -66
- package/dist/utils/bytes.js.map +0 -1
- package/dist/utils/compression.d.ts +0 -22
- package/dist/utils/compression.d.ts.map +0 -1
- package/dist/utils/compression.js +0 -129
- package/dist/utils/compression.js.map +0 -1
- package/dist/utils/crypto.d.ts +0 -13
- package/dist/utils/crypto.d.ts.map +0 -1
- package/dist/utils/crypto.js +0 -107
- package/dist/utils/crypto.js.map +0 -1
- package/dist/utils/id.d.ts +0 -3
- package/dist/utils/id.d.ts.map +0 -1
- package/dist/utils/id.js +0 -25
- package/dist/utils/id.js.map +0 -1
- package/dist/utils/index.d.ts +0 -10
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -10
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/internal-runtime.d.ts +0 -16
- package/dist/utils/internal-runtime.d.ts.map +0 -1
- package/dist/utils/internal-runtime.js +0 -14
- package/dist/utils/internal-runtime.js.map +0 -1
- package/dist/utils/object.d.ts +0 -2
- package/dist/utils/object.d.ts.map +0 -1
- package/dist/utils/object.js +0 -4
- package/dist/utils/object.js.map +0 -1
- package/dist/utils/realtime-change-index.d.ts +0 -9
- package/dist/utils/realtime-change-index.d.ts.map +0 -1
- package/dist/utils/realtime-change-index.js +0 -36
- package/dist/utils/realtime-change-index.js.map +0 -1
- package/dist/utils/realtime-connection-registry.d.ts +0 -40
- package/dist/utils/realtime-connection-registry.d.ts.map +0 -1
- package/dist/utils/realtime-connection-registry.js +0 -237
- package/dist/utils/realtime-connection-registry.js.map +0 -1
- package/dist/utils/table-registry.d.ts +0 -10
- package/dist/utils/table-registry.d.ts.map +0 -1
- package/dist/utils/table-registry.js +0 -54
- package/dist/utils/table-registry.js.map +0 -1
- package/dist/utils/url.d.ts +0 -3
- package/dist/utils/url.d.ts.map +0 -1
- package/dist/utils/url.js +0 -20
- package/dist/utils/url.js.map +0 -1
- package/src/blobs.ts +0 -219
- package/src/column-codecs.ts +0 -262
- package/src/conflict.ts +0 -92
- package/src/database.ts +0 -21
- package/src/dialect.ts +0 -8
- package/src/error-responses.ts +0 -423
- package/src/http/api-client.ts +0 -39
- package/src/http/blob.ts +0 -95
- package/src/http/generated/api.ts +0 -5241
- package/src/http/index.ts +0 -365
- package/src/http/shared.ts +0 -220
- package/src/http/transport-client.ts +0 -314
- package/src/kysely-column-codecs.ts +0 -585
- package/src/logger.ts +0 -38
- package/src/proxy/index.ts +0 -57
- package/src/schemas/blobs.ts +0 -110
- package/src/schemas/common.ts +0 -89
- package/src/schemas/index.ts +0 -7
- package/src/schemas/sync.ts +0 -517
- package/src/scopes/index.ts +0 -237
- package/src/sentry.ts +0 -200
- package/src/snapshot-chunks.ts +0 -964
- package/src/sync-packs.ts +0 -1096
- package/src/telemetry.ts +0 -243
- package/src/transforms.ts +0 -256
- package/src/types.ts +0 -267
- package/src/utils/bytes.ts +0 -75
- package/src/utils/compression.ts +0 -187
- package/src/utils/crypto.ts +0 -146
- package/src/utils/id.ts +0 -34
- package/src/utils/index.ts +0 -9
- package/src/utils/internal-runtime.ts +0 -39
- package/src/utils/object.ts +0 -3
- package/src/utils/realtime-change-index.ts +0 -44
- package/src/utils/realtime-connection-registry.ts +0 -286
- package/src/utils/table-registry.ts +0 -67
- package/src/utils/url.ts +0 -29
package/src/bytes.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primitive readers/writers for the SPEC.md Conventions table.
|
|
3
|
+
*
|
|
4
|
+
* All multi-byte integers are little-endian, fixed-width (no varints).
|
|
5
|
+
* The encoding is canonical: for every value there is exactly one valid
|
|
6
|
+
* byte sequence, so the reader rejects non-minimal presence bytes,
|
|
7
|
+
* out-of-order map keys, out-of-range bools, and i64 values outside the
|
|
8
|
+
* JS safe-integer contract.
|
|
9
|
+
*/
|
|
10
|
+
import { DecodeError } from './errors';
|
|
11
|
+
|
|
12
|
+
const MAX_SAFE_I64 = 9007199254740991n; // 2^53 - 1
|
|
13
|
+
const MIN_SAFE_I64 = -9007199254740991n;
|
|
14
|
+
|
|
15
|
+
const textEncoder = new TextEncoder();
|
|
16
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
17
|
+
|
|
18
|
+
export function utf8Encode(value: string): Uint8Array {
|
|
19
|
+
return textEncoder.encode(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Growable little-endian byte sink. Encoder misuse throws plain `Error`. */
|
|
23
|
+
export class ByteWriter {
|
|
24
|
+
#buffer = new Uint8Array(256);
|
|
25
|
+
#view = new DataView(this.#buffer.buffer);
|
|
26
|
+
#length = 0;
|
|
27
|
+
|
|
28
|
+
#reserve(extra: number): number {
|
|
29
|
+
const offset = this.#length;
|
|
30
|
+
const needed = offset + extra;
|
|
31
|
+
if (needed > this.#buffer.length) {
|
|
32
|
+
let capacity = this.#buffer.length;
|
|
33
|
+
while (capacity < needed) capacity *= 2;
|
|
34
|
+
const grown = new Uint8Array(capacity);
|
|
35
|
+
grown.set(this.#buffer.subarray(0, offset));
|
|
36
|
+
this.#buffer = grown;
|
|
37
|
+
this.#view = new DataView(grown.buffer);
|
|
38
|
+
}
|
|
39
|
+
this.#length = needed;
|
|
40
|
+
return offset;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
u8(value: number): void {
|
|
44
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xff) {
|
|
45
|
+
throw new Error(`u8 out of range: ${value}`);
|
|
46
|
+
}
|
|
47
|
+
const offset = this.#reserve(1);
|
|
48
|
+
this.#view.setUint8(offset, value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
u16(value: number): void {
|
|
52
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
|
|
53
|
+
throw new Error(`u16 out of range: ${value}`);
|
|
54
|
+
}
|
|
55
|
+
const offset = this.#reserve(2);
|
|
56
|
+
this.#view.setUint16(offset, value, true);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
u32(value: number): void {
|
|
60
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) {
|
|
61
|
+
throw new Error(`u32 out of range: ${value}`);
|
|
62
|
+
}
|
|
63
|
+
const offset = this.#reserve(4);
|
|
64
|
+
this.#view.setUint32(offset, value, true);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
i32(value: number): void {
|
|
68
|
+
if (!Number.isInteger(value) || value < -0x80000000 || value > 0x7fffffff) {
|
|
69
|
+
throw new Error(`i32 out of range: ${value}`);
|
|
70
|
+
}
|
|
71
|
+
const offset = this.#reserve(4);
|
|
72
|
+
this.#view.setInt32(offset, value, true);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
i64(value: number): void {
|
|
76
|
+
if (!Number.isSafeInteger(value)) {
|
|
77
|
+
throw new Error(`i64 outside the safe-integer contract: ${value}`);
|
|
78
|
+
}
|
|
79
|
+
const offset = this.#reserve(8);
|
|
80
|
+
this.#view.setBigInt64(offset, BigInt(value), true);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
f64(value: number): void {
|
|
84
|
+
const offset = this.#reserve(8);
|
|
85
|
+
this.#view.setFloat64(offset, value, true);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
bool(value: boolean): void {
|
|
89
|
+
this.u8(value ? 1 : 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
str(value: string): void {
|
|
93
|
+
const encoded = textEncoder.encode(value);
|
|
94
|
+
this.u32(encoded.length);
|
|
95
|
+
this.raw(encoded);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
bytes(value: Uint8Array): void {
|
|
99
|
+
this.u32(value.length);
|
|
100
|
+
this.raw(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
raw(value: Uint8Array): void {
|
|
104
|
+
const offset = this.#reserve(value.length);
|
|
105
|
+
this.#buffer.set(value, offset);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
opt<T>(value: T | undefined, write: (value: T) => void): void {
|
|
109
|
+
if (value === undefined) {
|
|
110
|
+
this.u8(0);
|
|
111
|
+
} else {
|
|
112
|
+
this.u8(1);
|
|
113
|
+
write(value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
finish(): Uint8Array {
|
|
118
|
+
return this.#buffer.slice(0, this.#length);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Bounds-checked little-endian reader. Failures throw `DecodeError`. */
|
|
123
|
+
export class ByteReader {
|
|
124
|
+
#data: Uint8Array;
|
|
125
|
+
#view: DataView;
|
|
126
|
+
#position = 0;
|
|
127
|
+
|
|
128
|
+
constructor(data: Uint8Array) {
|
|
129
|
+
this.#data = data;
|
|
130
|
+
this.#view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get remaining(): number {
|
|
134
|
+
return this.#data.length - this.#position;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#take(size: number): number {
|
|
138
|
+
if (this.remaining < size) {
|
|
139
|
+
throw new DecodeError(
|
|
140
|
+
'sync.invalid_request',
|
|
141
|
+
`truncated input: needed ${size} bytes, have ${this.remaining}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const offset = this.#position;
|
|
145
|
+
this.#position += size;
|
|
146
|
+
return offset;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
u8(): number {
|
|
150
|
+
return this.#view.getUint8(this.#take(1));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
u16(): number {
|
|
154
|
+
return this.#view.getUint16(this.#take(2), true);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
u32(): number {
|
|
158
|
+
return this.#view.getUint32(this.#take(4), true);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
i32(): number {
|
|
162
|
+
return this.#view.getInt32(this.#take(4), true);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
i64(): number {
|
|
166
|
+
const value = this.#view.getBigInt64(this.#take(8), true);
|
|
167
|
+
if (value > MAX_SAFE_I64 || value < MIN_SAFE_I64) {
|
|
168
|
+
throw new DecodeError(
|
|
169
|
+
'sync.invalid_request',
|
|
170
|
+
`i64 outside the safe-integer contract: ${value}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return Number(value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
f64(): number {
|
|
177
|
+
return this.#view.getFloat64(this.#take(8), true);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
bool(): boolean {
|
|
181
|
+
const byte = this.u8();
|
|
182
|
+
if (byte > 1) {
|
|
183
|
+
throw new DecodeError(
|
|
184
|
+
'sync.invalid_request',
|
|
185
|
+
`invalid bool byte 0x${byte.toString(16)}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return byte === 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
raw(size: number): Uint8Array {
|
|
192
|
+
const offset = this.#take(size);
|
|
193
|
+
return this.#data.slice(offset, offset + size);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
bytes(): Uint8Array {
|
|
197
|
+
return this.raw(this.u32());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
str(): string {
|
|
201
|
+
const encoded = this.bytes();
|
|
202
|
+
try {
|
|
203
|
+
return textDecoder.decode(encoded);
|
|
204
|
+
} catch {
|
|
205
|
+
throw new DecodeError('sync.invalid_request', 'invalid UTF-8 in string');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
opt<T>(read: () => T): T | undefined {
|
|
210
|
+
const presence = this.u8();
|
|
211
|
+
if (presence === 0) return undefined;
|
|
212
|
+
if (presence === 1) return read();
|
|
213
|
+
throw new DecodeError(
|
|
214
|
+
'sync.invalid_request',
|
|
215
|
+
`invalid option presence byte 0x${presence.toString(16)}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
expectFullyConsumed(what: string): void {
|
|
220
|
+
if (this.remaining !== 0) {
|
|
221
|
+
throw new DecodeError(
|
|
222
|
+
'sync.invalid_request',
|
|
223
|
+
`trailing bytes after ${what}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `map` of `str` → `list(str)` (scope maps, SPEC.md Conventions).
|
|
231
|
+
* Encoders emit keys in ascending code-unit order; the reader rejects
|
|
232
|
+
* duplicate or out-of-order keys (canonical encoding).
|
|
233
|
+
*/
|
|
234
|
+
export function writeStringListMap(
|
|
235
|
+
writer: ByteWriter,
|
|
236
|
+
map: Readonly<Record<string, readonly string[]>>,
|
|
237
|
+
): void {
|
|
238
|
+
const keys = Object.keys(map).sort();
|
|
239
|
+
writer.u32(keys.length);
|
|
240
|
+
for (const key of keys) {
|
|
241
|
+
writer.str(key);
|
|
242
|
+
const values = map[key] ?? [];
|
|
243
|
+
writer.u32(values.length);
|
|
244
|
+
for (const value of values) writer.str(value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function readStringListMap(
|
|
249
|
+
reader: ByteReader,
|
|
250
|
+
): Record<string, string[]> {
|
|
251
|
+
const count = reader.u32();
|
|
252
|
+
const entries: Array<[string, string[]]> = [];
|
|
253
|
+
let previousKey: string | undefined;
|
|
254
|
+
for (let i = 0; i < count; i++) {
|
|
255
|
+
const key = reader.str();
|
|
256
|
+
if (previousKey !== undefined && key <= previousKey) {
|
|
257
|
+
throw new DecodeError(
|
|
258
|
+
'sync.invalid_request',
|
|
259
|
+
`map keys must be unique and in ascending code-unit order (saw ${JSON.stringify(key)} after ${JSON.stringify(previousKey)})`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
previousKey = key;
|
|
263
|
+
const valueCount = reader.u32();
|
|
264
|
+
const values: string[] = [];
|
|
265
|
+
for (let j = 0; j < valueCount; j++) values.push(reader.str());
|
|
266
|
+
entries.push([key, values]);
|
|
267
|
+
}
|
|
268
|
+
return Object.fromEntries(entries);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `map` of `str` → `str` (stored scopes on change records, SPEC.md §4.5). */
|
|
272
|
+
export function writeStringMap(
|
|
273
|
+
writer: ByteWriter,
|
|
274
|
+
map: Readonly<Record<string, string>>,
|
|
275
|
+
): void {
|
|
276
|
+
const keys = Object.keys(map).sort();
|
|
277
|
+
writer.u32(keys.length);
|
|
278
|
+
for (const key of keys) {
|
|
279
|
+
writer.str(key);
|
|
280
|
+
const value = map[key];
|
|
281
|
+
writer.str(value ?? '');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function readStringMap(reader: ByteReader): Record<string, string> {
|
|
286
|
+
const count = reader.u32();
|
|
287
|
+
const entries: Array<[string, string]> = [];
|
|
288
|
+
let previousKey: string | undefined;
|
|
289
|
+
for (let i = 0; i < count; i++) {
|
|
290
|
+
const key = reader.str();
|
|
291
|
+
if (previousKey !== undefined && key <= previousKey) {
|
|
292
|
+
throw new DecodeError(
|
|
293
|
+
'sync.invalid_request',
|
|
294
|
+
`map keys must be unique and in ascending code-unit order (saw ${JSON.stringify(key)} after ${JSON.stringify(previousKey)})`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
previousKey = key;
|
|
298
|
+
entries.push([key, reader.str()]);
|
|
299
|
+
}
|
|
300
|
+
return Object.fromEntries(entries);
|
|
301
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON for digests (SPEC.md §11.2) — contractual across
|
|
3
|
+
* implementations: UTF-8, no insignificant whitespace, object keys sorted
|
|
4
|
+
* by code-unit, scope value lists sorted and deduplicated.
|
|
5
|
+
*
|
|
6
|
+
* Used for the scope digest (§3.5) and the `X-Syncular-Scopes` header
|
|
7
|
+
* (§5.5). Hashing itself is the host's job; this module only produces the
|
|
8
|
+
* canonical text.
|
|
9
|
+
*/
|
|
10
|
+
export function canonicalScopeJson(
|
|
11
|
+
scopes: Readonly<Record<string, readonly string[]>>,
|
|
12
|
+
): string {
|
|
13
|
+
const keys = Object.keys(scopes).sort();
|
|
14
|
+
const parts = keys.map((key) => {
|
|
15
|
+
const values = [...new Set(scopes[key] ?? [])].sort();
|
|
16
|
+
const list = values.map((value) => JSON.stringify(value)).join(',');
|
|
17
|
+
return `${JSON.stringify(key)}:[${list}]`;
|
|
18
|
+
});
|
|
19
|
+
return `{${parts.join(',')}}`;
|
|
20
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Protocol constants (SPEC.md §1.2, §9 are normative). */
|
|
2
|
+
|
|
3
|
+
/** SSP2 envelope wire version this codec implements. */
|
|
4
|
+
export const PROTOCOL_WIRE_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
/** Magic bytes opening every v2 sync envelope (SPEC.md §1.2). */
|
|
7
|
+
export const SYNC_PACK_MAGIC = 'SSP2';
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side encryption primitives (SPEC.md §5.11).
|
|
3
|
+
*
|
|
4
|
+
* The byte-exact ciphertext envelope, the declared-type value serializer,
|
|
5
|
+
* and AES-256-GCM encrypt/decrypt over WebCrypto (a global — this keeps
|
|
6
|
+
* `@syncular/core` dependency-free). These are the codec-level pieces shared
|
|
7
|
+
* by the wire boundary: the client encrypts a configured column's plaintext
|
|
8
|
+
* value here before the row codec (§2.4) sees it as a `bytes` value, and
|
|
9
|
+
* decrypts on apply. The row codec itself never touches this file.
|
|
10
|
+
*
|
|
11
|
+
* X25519 sealed-box key wrapping (the "async encryption" utilities) lives in
|
|
12
|
+
* `@syncular/crypto` (it needs an audited curve implementation); this module
|
|
13
|
+
* is the symmetric core and the envelope both sides agree on byte-for-byte.
|
|
14
|
+
*/
|
|
15
|
+
import { ByteReader, ByteWriter } from './bytes';
|
|
16
|
+
|
|
17
|
+
/** The pre-flip application type of an encrypted column (SPEC.md §5.11). */
|
|
18
|
+
export type DeclaredType =
|
|
19
|
+
| 'string'
|
|
20
|
+
| 'integer'
|
|
21
|
+
| 'float'
|
|
22
|
+
| 'boolean'
|
|
23
|
+
| 'json'
|
|
24
|
+
| 'blob_ref'
|
|
25
|
+
| 'bytes';
|
|
26
|
+
|
|
27
|
+
/** A non-null row value, per the declared type. `crdt` is never encrypted. */
|
|
28
|
+
export type PlainValue = string | number | boolean | Uint8Array;
|
|
29
|
+
|
|
30
|
+
/** Envelope version byte (SPEC.md §5.11). */
|
|
31
|
+
export const ENVELOPE_VERSION = 0x01;
|
|
32
|
+
|
|
33
|
+
/** AES-GCM nonce length (96-bit, the GCM standard). */
|
|
34
|
+
export const NONCE_LENGTH = 12;
|
|
35
|
+
|
|
36
|
+
/** Symmetric key length: AES-256. */
|
|
37
|
+
export const KEY_LENGTH = 32;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Client-local decrypt failure (SPEC.md §5.11, §10.3). Never on the wire —
|
|
41
|
+
* the `client.` family is client-only. Raised at the apply seam for an
|
|
42
|
+
* unknown envelope version, unknown `keyId`, GCM auth failure (wrong key), a
|
|
43
|
+
* malformed envelope, or a post-decrypt value-parse failure. Not retryable.
|
|
44
|
+
*/
|
|
45
|
+
export class DecryptError extends Error {
|
|
46
|
+
override readonly name = 'DecryptError';
|
|
47
|
+
readonly code = 'client.decrypt_failed';
|
|
48
|
+
readonly retryable = false;
|
|
49
|
+
|
|
50
|
+
constructor(message: string) {
|
|
51
|
+
super(message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Injectable nonce source (SPEC.md §5.11 nonce discipline). Production uses
|
|
57
|
+
* {@link secureRandomNonce}; crypto golden vectors inject a fixed nonce. A
|
|
58
|
+
* fixed nonce MUST NOT be reachable from a production encode path.
|
|
59
|
+
*/
|
|
60
|
+
export type NonceSource = () => Uint8Array;
|
|
61
|
+
|
|
62
|
+
export function secureRandomNonce(): Uint8Array {
|
|
63
|
+
const nonce = new Uint8Array(NONCE_LENGTH);
|
|
64
|
+
crypto.getRandomValues(nonce);
|
|
65
|
+
return nonce;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Value serializer — declared type ⇄ canonical plaintext bytes (§5.11)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const textEncoder = new TextEncoder();
|
|
73
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Serialize a declared-type value to the canonical plaintext bytes fed to
|
|
77
|
+
* GCM (SPEC.md §5.11 value serializer). Not a re-run of the row codec — this
|
|
78
|
+
* is the self-describing per-`declaredType` encoding both cores agree on.
|
|
79
|
+
*/
|
|
80
|
+
export function serializePlain(
|
|
81
|
+
declaredType: DeclaredType,
|
|
82
|
+
value: PlainValue,
|
|
83
|
+
): Uint8Array {
|
|
84
|
+
switch (declaredType) {
|
|
85
|
+
case 'string':
|
|
86
|
+
case 'json':
|
|
87
|
+
case 'blob_ref':
|
|
88
|
+
if (typeof value !== 'string') {
|
|
89
|
+
throw new Error(`encrypted ${declaredType} column requires a string`);
|
|
90
|
+
}
|
|
91
|
+
return textEncoder.encode(value);
|
|
92
|
+
case 'integer': {
|
|
93
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
94
|
+
throw new Error('encrypted integer column requires a safe integer');
|
|
95
|
+
}
|
|
96
|
+
const out = new Uint8Array(8);
|
|
97
|
+
new DataView(out.buffer).setBigInt64(0, BigInt(value), true);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
case 'float': {
|
|
101
|
+
if (typeof value !== 'number') {
|
|
102
|
+
throw new Error('encrypted float column requires a number');
|
|
103
|
+
}
|
|
104
|
+
const out = new Uint8Array(8);
|
|
105
|
+
new DataView(out.buffer).setFloat64(0, value, true);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
case 'boolean':
|
|
109
|
+
if (typeof value !== 'boolean') {
|
|
110
|
+
throw new Error('encrypted boolean column requires a boolean');
|
|
111
|
+
}
|
|
112
|
+
return new Uint8Array([value ? 1 : 0]);
|
|
113
|
+
case 'bytes':
|
|
114
|
+
if (!(value instanceof Uint8Array)) {
|
|
115
|
+
throw new Error('encrypted bytes column requires a Uint8Array');
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse the decrypted plaintext bytes back to a declared-type value (SPEC.md
|
|
123
|
+
* §5.11). A `json`/`blob_ref` value is re-validated as the row codec would
|
|
124
|
+
* (§2.4); any parse failure is a decrypt failure.
|
|
125
|
+
*/
|
|
126
|
+
export function deserializePlain(
|
|
127
|
+
declaredType: DeclaredType,
|
|
128
|
+
bytes: Uint8Array,
|
|
129
|
+
): PlainValue {
|
|
130
|
+
switch (declaredType) {
|
|
131
|
+
case 'string':
|
|
132
|
+
case 'json':
|
|
133
|
+
case 'blob_ref': {
|
|
134
|
+
let text: string;
|
|
135
|
+
try {
|
|
136
|
+
text = textDecoder.decode(bytes);
|
|
137
|
+
} catch {
|
|
138
|
+
throw new DecryptError(
|
|
139
|
+
`decrypted ${declaredType} value is not valid UTF-8`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (declaredType === 'json') {
|
|
143
|
+
try {
|
|
144
|
+
JSON.parse(text);
|
|
145
|
+
} catch {
|
|
146
|
+
throw new DecryptError('decrypted json value does not parse');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return text;
|
|
150
|
+
}
|
|
151
|
+
case 'integer': {
|
|
152
|
+
if (bytes.length !== 8) {
|
|
153
|
+
throw new DecryptError('decrypted integer must be 8 bytes');
|
|
154
|
+
}
|
|
155
|
+
const big = new DataView(bytes.buffer, bytes.byteOffset, 8).getBigInt64(
|
|
156
|
+
0,
|
|
157
|
+
true,
|
|
158
|
+
);
|
|
159
|
+
const num = Number(big);
|
|
160
|
+
if (!Number.isSafeInteger(num)) {
|
|
161
|
+
throw new DecryptError('decrypted integer outside safe range');
|
|
162
|
+
}
|
|
163
|
+
return num;
|
|
164
|
+
}
|
|
165
|
+
case 'float': {
|
|
166
|
+
if (bytes.length !== 8) {
|
|
167
|
+
throw new DecryptError('decrypted float must be 8 bytes');
|
|
168
|
+
}
|
|
169
|
+
return new DataView(bytes.buffer, bytes.byteOffset, 8).getFloat64(
|
|
170
|
+
0,
|
|
171
|
+
true,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
case 'boolean': {
|
|
175
|
+
if (bytes.length !== 1 || (bytes[0] !== 0 && bytes[0] !== 1)) {
|
|
176
|
+
throw new DecryptError('decrypted boolean must be one 0x00/0x01 byte');
|
|
177
|
+
}
|
|
178
|
+
return bytes[0] === 1;
|
|
179
|
+
}
|
|
180
|
+
case 'bytes':
|
|
181
|
+
return bytes;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Envelope — 0x01 | keyIdLen(u8) | keyId | nonce(12) | ct+tag (§5.11)
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export interface Envelope {
|
|
190
|
+
readonly keyId: string;
|
|
191
|
+
readonly nonce: Uint8Array;
|
|
192
|
+
/** AES-256-GCM ciphertext with the 16-byte tag appended. */
|
|
193
|
+
readonly ciphertext: Uint8Array;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function encodeEnvelope(envelope: Envelope): Uint8Array {
|
|
197
|
+
const keyIdBytes = textEncoder.encode(envelope.keyId);
|
|
198
|
+
if (keyIdBytes.length > 0xff) {
|
|
199
|
+
throw new Error('keyId exceeds 255 UTF-8 bytes');
|
|
200
|
+
}
|
|
201
|
+
if (envelope.nonce.length !== NONCE_LENGTH) {
|
|
202
|
+
throw new Error(`nonce must be ${NONCE_LENGTH} bytes`);
|
|
203
|
+
}
|
|
204
|
+
const writer = new ByteWriter();
|
|
205
|
+
writer.u8(ENVELOPE_VERSION);
|
|
206
|
+
writer.u8(keyIdBytes.length);
|
|
207
|
+
writer.raw(keyIdBytes);
|
|
208
|
+
writer.raw(envelope.nonce);
|
|
209
|
+
writer.raw(envelope.ciphertext);
|
|
210
|
+
return writer.finish();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function decodeEnvelope(bytes: Uint8Array): Envelope {
|
|
214
|
+
const reader = new ByteReader(bytes);
|
|
215
|
+
let version: number;
|
|
216
|
+
let keyIdLen: number;
|
|
217
|
+
try {
|
|
218
|
+
version = reader.u8();
|
|
219
|
+
keyIdLen = reader.u8();
|
|
220
|
+
} catch {
|
|
221
|
+
throw new DecryptError('encrypted value: envelope truncated');
|
|
222
|
+
}
|
|
223
|
+
if (version !== ENVELOPE_VERSION) {
|
|
224
|
+
throw new DecryptError(
|
|
225
|
+
`encrypted value: unknown envelope version 0x${version.toString(16)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
let keyIdBytes: Uint8Array;
|
|
229
|
+
let nonce: Uint8Array;
|
|
230
|
+
try {
|
|
231
|
+
keyIdBytes = reader.raw(keyIdLen);
|
|
232
|
+
nonce = reader.raw(NONCE_LENGTH);
|
|
233
|
+
} catch {
|
|
234
|
+
throw new DecryptError('encrypted value: envelope truncated');
|
|
235
|
+
}
|
|
236
|
+
// The remaining bytes are ciphertext+tag; must be at least the 16-byte tag.
|
|
237
|
+
const ciphertext = bytes.subarray(2 + keyIdLen + NONCE_LENGTH);
|
|
238
|
+
if (ciphertext.length < 16) {
|
|
239
|
+
throw new DecryptError('encrypted value: ciphertext shorter than GCM tag');
|
|
240
|
+
}
|
|
241
|
+
let keyId: string;
|
|
242
|
+
try {
|
|
243
|
+
keyId = textDecoder.decode(keyIdBytes);
|
|
244
|
+
} catch {
|
|
245
|
+
throw new DecryptError('encrypted value: keyId is not valid UTF-8');
|
|
246
|
+
}
|
|
247
|
+
return { keyId, nonce, ciphertext: ciphertext.slice() };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// AES-256-GCM over WebCrypto
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Copy into a fresh `ArrayBuffer`-backed view. WebCrypto's `BufferSource`
|
|
256
|
+
* type rejects a `Uint8Array<ArrayBufferLike>` (it could be SharedArrayBuffer);
|
|
257
|
+
* a plain copy is unambiguously `ArrayBuffer`-backed.
|
|
258
|
+
*/
|
|
259
|
+
function toBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
260
|
+
const copy = new Uint8Array(bytes.length);
|
|
261
|
+
copy.set(bytes);
|
|
262
|
+
return copy.buffer;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function importAesKey(key: Uint8Array): Promise<CryptoKey> {
|
|
266
|
+
if (key.length !== KEY_LENGTH) {
|
|
267
|
+
throw new Error(`AES-256-GCM key must be ${KEY_LENGTH} bytes`);
|
|
268
|
+
}
|
|
269
|
+
return crypto.subtle.importKey('raw', toBuffer(key), 'AES-GCM', false, [
|
|
270
|
+
'encrypt',
|
|
271
|
+
'decrypt',
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Encrypt one declared-type value into a §5.11 envelope. `nonceSource`
|
|
277
|
+
* defaults to a secure RNG; vectors inject a fixed nonce.
|
|
278
|
+
*/
|
|
279
|
+
export async function encryptValue(
|
|
280
|
+
declaredType: DeclaredType,
|
|
281
|
+
value: PlainValue,
|
|
282
|
+
keyId: string,
|
|
283
|
+
key: Uint8Array,
|
|
284
|
+
nonceSource: NonceSource = secureRandomNonce,
|
|
285
|
+
): Promise<Uint8Array> {
|
|
286
|
+
const plaintext = serializePlain(declaredType, value);
|
|
287
|
+
const nonce = nonceSource();
|
|
288
|
+
if (nonce.length !== NONCE_LENGTH) {
|
|
289
|
+
throw new Error(`nonce source must yield ${NONCE_LENGTH} bytes`);
|
|
290
|
+
}
|
|
291
|
+
const cryptoKey = await importAesKey(key);
|
|
292
|
+
const ct = new Uint8Array(
|
|
293
|
+
await crypto.subtle.encrypt(
|
|
294
|
+
{ name: 'AES-GCM', iv: toBuffer(nonce), tagLength: 128 },
|
|
295
|
+
cryptoKey,
|
|
296
|
+
toBuffer(plaintext),
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
return encodeEnvelope({ keyId, nonce, ciphertext: ct });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Decrypt a §5.11 envelope back to a declared-type value. `keyProvider`
|
|
304
|
+
* resolves the envelope's `keyId` to key bytes; a missing key or GCM tag
|
|
305
|
+
* mismatch is {@link DecryptError} (`client.decrypt_failed`).
|
|
306
|
+
*/
|
|
307
|
+
export async function decryptValue(
|
|
308
|
+
declaredType: DeclaredType,
|
|
309
|
+
envelopeBytes: Uint8Array,
|
|
310
|
+
keyProvider: (keyId: string) => Uint8Array | undefined,
|
|
311
|
+
): Promise<PlainValue> {
|
|
312
|
+
const envelope = decodeEnvelope(envelopeBytes);
|
|
313
|
+
const key = keyProvider(envelope.keyId);
|
|
314
|
+
if (key === undefined) {
|
|
315
|
+
throw new DecryptError(
|
|
316
|
+
`no key for keyId ${JSON.stringify(envelope.keyId)}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const cryptoKey = await importAesKey(key);
|
|
320
|
+
let plaintext: Uint8Array;
|
|
321
|
+
try {
|
|
322
|
+
plaintext = new Uint8Array(
|
|
323
|
+
await crypto.subtle.decrypt(
|
|
324
|
+
{ name: 'AES-GCM', iv: toBuffer(envelope.nonce), tagLength: 128 },
|
|
325
|
+
cryptoKey,
|
|
326
|
+
toBuffer(envelope.ciphertext),
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
} catch {
|
|
330
|
+
throw new DecryptError(
|
|
331
|
+
`GCM authentication failed for keyId ${JSON.stringify(envelope.keyId)} (wrong key or corrupt ciphertext)`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return deserializePlain(declaredType, plaintext);
|
|
335
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named decode errors (SPEC.md Conventions + §10).
|
|
3
|
+
*
|
|
4
|
+
* Every decode failure in the reference codec throws a `DecodeError` whose
|
|
5
|
+
* `code` is a stable identifier from the SPEC.md §10 catalog (structural
|
|
6
|
+
* failures use `sync.invalid_request`; frame-specific rules use their named
|
|
7
|
+
* code, e.g. `sync.empty_commit`). Golden vector negative cases assert on
|
|
8
|
+
* `code`, never on `message`.
|
|
9
|
+
*/
|
|
10
|
+
export class DecodeError extends Error {
|
|
11
|
+
override readonly name = 'DecodeError';
|
|
12
|
+
readonly code: string;
|
|
13
|
+
|
|
14
|
+
constructor(code: string, message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|