@syncular/core 0.0.1 → 0.0.2-126
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 +24 -0
- package/dist/blobs.d.ts +9 -4
- package/dist/blobs.d.ts.map +1 -1
- package/dist/blobs.js +0 -12
- package/dist/blobs.js.map +1 -1
- package/dist/column-codecs.d.ts +55 -0
- package/dist/column-codecs.d.ts.map +1 -0
- package/dist/column-codecs.js +124 -0
- package/dist/column-codecs.js.map +1 -0
- package/dist/index.d.ts +11 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -7
- package/dist/index.js.map +1 -1
- package/dist/kysely-serialize.d.ts +1 -1
- package/dist/kysely-serialize.d.ts.map +1 -1
- package/dist/logger.d.ts +7 -32
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +6 -40
- package/dist/logger.js.map +1 -1
- package/dist/proxy/types.d.ts +0 -9
- package/dist/proxy/types.d.ts.map +1 -1
- package/dist/schemas/index.js +3 -3
- package/dist/schemas/sync.d.ts +120 -6
- package/dist/schemas/sync.d.ts.map +1 -1
- package/dist/schemas/sync.js +17 -3
- package/dist/schemas/sync.js.map +1 -1
- package/dist/scopes/index.d.ts +39 -64
- package/dist/scopes/index.d.ts.map +1 -1
- package/dist/scopes/index.js +9 -154
- package/dist/scopes/index.js.map +1 -1
- package/dist/snapshot-chunks.d.ts +26 -0
- package/dist/snapshot-chunks.d.ts.map +1 -0
- package/dist/snapshot-chunks.js +89 -0
- package/dist/snapshot-chunks.js.map +1 -0
- package/dist/telemetry.d.ts +114 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +113 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/types.d.ts +12 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/id.d.ts +2 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +8 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/object.d.ts +2 -0
- package/dist/utils/object.d.ts.map +1 -0
- package/dist/utils/object.js +4 -0
- package/dist/utils/object.js.map +1 -0
- package/package.json +28 -8
- package/src/__tests__/telemetry.test.ts +170 -0
- package/src/__tests__/utils.test.ts +27 -0
- package/src/blobs.ts +15 -14
- package/src/column-codecs.ts +228 -0
- package/src/index.ts +15 -41
- package/src/kysely-serialize.ts +1 -1
- package/src/logger.ts +10 -68
- package/src/proxy/types.ts +0 -10
- package/src/schemas/sync.ts +27 -3
- package/src/scopes/index.ts +72 -200
- package/src/snapshot-chunks.ts +112 -0
- package/src/telemetry.ts +238 -0
- package/src/types.ts +14 -18
- package/src/utils/id.ts +7 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/object.ts +3 -0
package/src/schemas/sync.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
+
import {
|
|
12
|
+
SYNC_SNAPSHOT_CHUNK_COMPRESSION,
|
|
13
|
+
SYNC_SNAPSHOT_CHUNK_ENCODING,
|
|
14
|
+
} from '../snapshot-chunks';
|
|
11
15
|
|
|
12
16
|
// ============================================================================
|
|
13
17
|
// Operation Types
|
|
@@ -117,7 +121,7 @@ export type SyncBootstrapState = z.infer<typeof SyncBootstrapStateSchema>;
|
|
|
117
121
|
|
|
118
122
|
export const SyncSubscriptionRequestSchema = z.object({
|
|
119
123
|
id: z.string().min(1),
|
|
120
|
-
|
|
124
|
+
table: z.string().min(1),
|
|
121
125
|
scopes: ScopeValuesSchema,
|
|
122
126
|
params: z.record(z.string(), z.unknown()).optional(),
|
|
123
127
|
cursor: z.number().int(),
|
|
@@ -163,8 +167,8 @@ export const SyncSnapshotChunkRefSchema = z.object({
|
|
|
163
167
|
id: z.string(),
|
|
164
168
|
byteLength: z.number().int(),
|
|
165
169
|
sha256: z.string(),
|
|
166
|
-
encoding: z.literal(
|
|
167
|
-
compression: z.literal(
|
|
170
|
+
encoding: z.literal(SYNC_SNAPSHOT_CHUNK_ENCODING),
|
|
171
|
+
compression: z.literal(SYNC_SNAPSHOT_CHUNK_COMPRESSION),
|
|
168
172
|
});
|
|
169
173
|
|
|
170
174
|
export type SyncSnapshotChunkRef = z.infer<typeof SyncSnapshotChunkRefSchema>;
|
|
@@ -200,3 +204,23 @@ export const SyncPullResponseSchema = z.object({
|
|
|
200
204
|
});
|
|
201
205
|
|
|
202
206
|
export type SyncPullResponse = z.infer<typeof SyncPullResponseSchema>;
|
|
207
|
+
|
|
208
|
+
// ============================================================================
|
|
209
|
+
// Combined Sync Request/Response Schemas
|
|
210
|
+
// ============================================================================
|
|
211
|
+
|
|
212
|
+
export const SyncCombinedRequestSchema = z.object({
|
|
213
|
+
clientId: z.string().min(1),
|
|
214
|
+
push: SyncPushRequestSchema.omit({ clientId: true }).optional(),
|
|
215
|
+
pull: SyncPullRequestSchema.omit({ clientId: true }).optional(),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
export type SyncCombinedRequest = z.infer<typeof SyncCombinedRequestSchema>;
|
|
219
|
+
|
|
220
|
+
export const SyncCombinedResponseSchema = z.object({
|
|
221
|
+
ok: z.literal(true),
|
|
222
|
+
push: SyncPushResponseSchema.optional(),
|
|
223
|
+
pull: SyncPullResponseSchema.optional(),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
export type SyncCombinedResponse = z.infer<typeof SyncCombinedResponseSchema>;
|
package/src/scopes/index.ts
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export type ScopePattern = string;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Scope value for a single scope key.
|
|
18
|
+
*/
|
|
19
|
+
export type ScopeValue = string | string[];
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
22
|
* Scope values - the actual values for scope variables.
|
|
18
23
|
* Values can be single strings or arrays (for multi-value subscriptions).
|
|
@@ -22,7 +27,7 @@ export type ScopePattern = string;
|
|
|
22
27
|
* { project_id: ['P1', 'P2'] }
|
|
23
28
|
* { year: '2025', month: '03' }
|
|
24
29
|
*/
|
|
25
|
-
export type ScopeValues = Record<string,
|
|
30
|
+
export type ScopeValues = Record<string, ScopeValue>;
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
33
|
* Stored scopes on a change - always single values (not arrays).
|
|
@@ -34,19 +39,57 @@ export type ScopeValues = Record<string, string | string[]>;
|
|
|
34
39
|
export type StoredScopes = Record<string, string>;
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
|
-
*
|
|
38
|
-
* Maps scope patterns to their variable types.
|
|
42
|
+
* Extract scope keys from a scope pattern at the type level.
|
|
39
43
|
*
|
|
40
44
|
* @example
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
* ScopeKeysFromPattern<'user:{user_id}'> // 'user_id'
|
|
46
|
+
* ScopeKeysFromPattern<'event:{year}:{month}'> // 'year' | 'month'
|
|
47
|
+
*/
|
|
48
|
+
export type ScopeKeysFromPattern<Pattern extends ScopePattern> =
|
|
49
|
+
string extends Pattern
|
|
50
|
+
? string
|
|
51
|
+
: Pattern extends `${string}{${infer Key}}${infer Rest}`
|
|
52
|
+
? Key | ScopeKeysFromPattern<Rest>
|
|
53
|
+
: never;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the pattern string from a scope definition.
|
|
57
|
+
*/
|
|
58
|
+
export type ScopePatternFromDefinition<Definition extends ScopeDefinition> =
|
|
59
|
+
Definition extends ScopePattern
|
|
60
|
+
? Definition
|
|
61
|
+
: Definition extends { pattern: infer Pattern extends ScopePattern }
|
|
62
|
+
? Pattern
|
|
63
|
+
: never;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract scope keys from a list of scope definitions.
|
|
67
|
+
*/
|
|
68
|
+
export type ScopeKeysFromDefinitions<
|
|
69
|
+
Definitions extends readonly ScopeDefinition[],
|
|
70
|
+
> = ScopeKeysFromPattern<ScopePatternFromDefinition<Definitions[number]>>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scope values constrained to known scope keys.
|
|
74
|
+
*
|
|
75
|
+
* Unknown keys are rejected at compile-time when literals are used.
|
|
76
|
+
*/
|
|
77
|
+
export type ScopeValuesForKeys<ScopeKeys extends string> = Partial<
|
|
78
|
+
Record<ScopeKeys, ScopeValue>
|
|
79
|
+
>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Scope values inferred from scope definitions.
|
|
46
83
|
*/
|
|
47
|
-
export type
|
|
48
|
-
|
|
49
|
-
|
|
84
|
+
export type ScopeValuesFromPatterns<
|
|
85
|
+
Definitions extends readonly ScopeDefinition[],
|
|
86
|
+
> = ScopeValuesForKeys<ScopeKeysFromDefinitions<Definitions>>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Stored scopes constrained to known scope keys.
|
|
90
|
+
*/
|
|
91
|
+
export type StoredScopesForKeys<ScopeKeys extends string> = Partial<
|
|
92
|
+
Record<ScopeKeys, string>
|
|
50
93
|
>;
|
|
51
94
|
|
|
52
95
|
/**
|
|
@@ -64,7 +107,21 @@ export type SharedScopesDefinition = Record<
|
|
|
64
107
|
* ]
|
|
65
108
|
* ```
|
|
66
109
|
*/
|
|
67
|
-
export type ScopeDefinition =
|
|
110
|
+
export type ScopeDefinition =
|
|
111
|
+
| ScopePattern
|
|
112
|
+
| { pattern: ScopePattern; column: string };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Keep scope definitions as a typed tuple for downstream inference.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* const scopes = defineScopePatterns(['user:{user_id}'] as const);
|
|
119
|
+
*/
|
|
120
|
+
export function defineScopePatterns<
|
|
121
|
+
const Definitions extends readonly ScopeDefinition[],
|
|
122
|
+
>(scopes: Definitions): Definitions {
|
|
123
|
+
return scopes;
|
|
124
|
+
}
|
|
68
125
|
|
|
69
126
|
// ── Pattern parsing (internal helpers) ───────────────────────────────
|
|
70
127
|
|
|
@@ -87,36 +144,6 @@ function extractPlaceholder(pattern: string): {
|
|
|
87
144
|
};
|
|
88
145
|
}
|
|
89
146
|
|
|
90
|
-
/**
|
|
91
|
-
* Match a scope string against a pattern and extract the value.
|
|
92
|
-
*/
|
|
93
|
-
function matchScopePattern(pattern: string, scopeKey: string): string | null {
|
|
94
|
-
const parsed = extractPlaceholder(pattern);
|
|
95
|
-
if (!parsed) {
|
|
96
|
-
// No placeholder - exact match
|
|
97
|
-
return pattern === scopeKey ? '' : null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const { prefix, suffix } = parsed;
|
|
101
|
-
|
|
102
|
-
if (!scopeKey.startsWith(prefix)) {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (suffix && !scopeKey.endsWith(suffix)) {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const valueStart = prefix.length;
|
|
111
|
-
const valueEnd = suffix ? scopeKey.length - suffix.length : scopeKey.length;
|
|
112
|
-
|
|
113
|
-
if (valueStart >= valueEnd) {
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return scopeKey.slice(valueStart, valueEnd);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
147
|
/**
|
|
121
148
|
* Extract the placeholder name from a pattern.
|
|
122
149
|
*/
|
|
@@ -125,33 +152,6 @@ function getPlaceholderName(pattern: string): string | null {
|
|
|
125
152
|
return parsed?.placeholder ?? null;
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
// ── Pattern operations (public) ──────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Build a scope string from a pattern and value.
|
|
132
|
-
*
|
|
133
|
-
* @example
|
|
134
|
-
* buildScopeKey('user:{user_id}', '123') // 'user:123'
|
|
135
|
-
*/
|
|
136
|
-
export function buildScopeKey(pattern: string, value: string): string {
|
|
137
|
-
const parsed = extractPlaceholder(pattern);
|
|
138
|
-
if (!parsed) {
|
|
139
|
-
return pattern;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return `${parsed.prefix}${value}${parsed.suffix}`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Check if a scope string matches a pattern.
|
|
147
|
-
*/
|
|
148
|
-
export function scopeKeyMatchesPattern(
|
|
149
|
-
pattern: string,
|
|
150
|
-
scopeKey: string
|
|
151
|
-
): boolean {
|
|
152
|
-
return matchScopePattern(pattern, scopeKey) !== null;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
155
|
/**
|
|
156
156
|
* Normalize scope definitions to a pattern-to-column map.
|
|
157
157
|
*
|
|
@@ -160,9 +160,9 @@ export function scopeKeyMatchesPattern(
|
|
|
160
160
|
* // → { 'user:{user_id}': 'user_id' }
|
|
161
161
|
*/
|
|
162
162
|
export function normalizeScopes(
|
|
163
|
-
scopes: ScopeDefinition[]
|
|
164
|
-
): Record<
|
|
165
|
-
const result: Record<
|
|
163
|
+
scopes: readonly ScopeDefinition[]
|
|
164
|
+
): Record<ScopePattern, string> {
|
|
165
|
+
const result: Record<ScopePattern, string> = {};
|
|
166
166
|
for (const scope of scopes) {
|
|
167
167
|
if (typeof scope === 'string') {
|
|
168
168
|
const placeholder = getPlaceholderName(scope);
|
|
@@ -179,22 +179,6 @@ export function normalizeScopes(
|
|
|
179
179
|
return result;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
/**
|
|
183
|
-
* Find the matching pattern and extract value from a scope string.
|
|
184
|
-
*/
|
|
185
|
-
export function findMatchingPattern(
|
|
186
|
-
patterns: Record<string, string>,
|
|
187
|
-
scopeKey: string
|
|
188
|
-
): { pattern: string; column: string; value: string } | null {
|
|
189
|
-
for (const [pattern, column] of Object.entries(patterns)) {
|
|
190
|
-
const value = matchScopePattern(pattern, scopeKey);
|
|
191
|
-
if (value !== null) {
|
|
192
|
-
return { pattern, column, value };
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
182
|
// ── Value operations (public) ────────────────────────────────────────
|
|
199
183
|
|
|
200
184
|
/**
|
|
@@ -209,115 +193,3 @@ export function extractScopeVars(pattern: ScopePattern): string[] {
|
|
|
209
193
|
if (!matches) return [];
|
|
210
194
|
return matches.map((m) => m.slice(1, -1));
|
|
211
195
|
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Build scope values from a row based on a scope pattern.
|
|
215
|
-
* Uses the pattern's variable names to extract values from the row.
|
|
216
|
-
*
|
|
217
|
-
* @example
|
|
218
|
-
* buildScopeValuesFromRow('project:{project_id}', { id: '1', project_id: 'P1' })
|
|
219
|
-
* // { project_id: 'P1' }
|
|
220
|
-
*/
|
|
221
|
-
export function buildScopeValuesFromRow(
|
|
222
|
-
pattern: ScopePattern,
|
|
223
|
-
row: Record<string, unknown>
|
|
224
|
-
): StoredScopes {
|
|
225
|
-
const vars = extractScopeVars(pattern);
|
|
226
|
-
const result: StoredScopes = {};
|
|
227
|
-
|
|
228
|
-
for (const varName of vars) {
|
|
229
|
-
const value = row[varName];
|
|
230
|
-
if (value === null || value === undefined) {
|
|
231
|
-
result[varName] = '';
|
|
232
|
-
} else {
|
|
233
|
-
result[varName] = String(value);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return result;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Merge multiple scope value objects into one.
|
|
242
|
-
* Later values override earlier ones.
|
|
243
|
-
*/
|
|
244
|
-
export function mergeScopeValues(...sources: StoredScopes[]): StoredScopes {
|
|
245
|
-
const result: StoredScopes = {};
|
|
246
|
-
for (const source of sources) {
|
|
247
|
-
for (const [key, value] of Object.entries(source)) {
|
|
248
|
-
result[key] = value;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return result;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Check if two stored scopes are equal.
|
|
256
|
-
*/
|
|
257
|
-
export function scopesEqual(a: StoredScopes, b: StoredScopes): boolean {
|
|
258
|
-
const keysA = Object.keys(a).sort();
|
|
259
|
-
const keysB = Object.keys(b).sort();
|
|
260
|
-
|
|
261
|
-
if (keysA.length !== keysB.length) return false;
|
|
262
|
-
|
|
263
|
-
for (let i = 0; i < keysA.length; i++) {
|
|
264
|
-
if (keysA[i] !== keysB[i]) return false;
|
|
265
|
-
if (a[keysA[i]!] !== b[keysB[i]!]) return false;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return true;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Check if stored scopes match a subscription's scope values.
|
|
273
|
-
* Handles array values in subscription (OR semantics).
|
|
274
|
-
* Missing keys in subscription are treated as wildcards (match any).
|
|
275
|
-
*
|
|
276
|
-
* @example
|
|
277
|
-
* scopesMatchSubscription({ project_id: 'P1' }, { project_id: 'P1' }) // true
|
|
278
|
-
* scopesMatchSubscription({ project_id: 'P1' }, { project_id: ['P1', 'P2'] }) // true
|
|
279
|
-
* scopesMatchSubscription({ project_id: 'P1' }, { project_id: 'P2' }) // false
|
|
280
|
-
*/
|
|
281
|
-
export function scopesMatchSubscription(
|
|
282
|
-
stored: StoredScopes,
|
|
283
|
-
subscription: ScopeValues
|
|
284
|
-
): boolean {
|
|
285
|
-
for (const [key, subValue] of Object.entries(subscription)) {
|
|
286
|
-
const storedValue = stored[key];
|
|
287
|
-
if (storedValue === undefined) return false;
|
|
288
|
-
|
|
289
|
-
if (Array.isArray(subValue)) {
|
|
290
|
-
if (!subValue.includes(storedValue)) return false;
|
|
291
|
-
} else {
|
|
292
|
-
if (storedValue !== subValue) return false;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return true;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Normalize scope values to always use arrays (for consistent handling).
|
|
301
|
-
*/
|
|
302
|
-
export function normalizeScopeValues(
|
|
303
|
-
values: ScopeValues
|
|
304
|
-
): Record<string, string[]> {
|
|
305
|
-
const result: Record<string, string[]> = {};
|
|
306
|
-
for (const [key, value] of Object.entries(values)) {
|
|
307
|
-
result[key] = Array.isArray(value) ? value : [value];
|
|
308
|
-
}
|
|
309
|
-
return result;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Get all scope variable names from a set of patterns.
|
|
314
|
-
*/
|
|
315
|
-
export function getAllScopeVars(patterns: ScopePattern[]): string[] {
|
|
316
|
-
const vars = new Set<string>();
|
|
317
|
-
for (const pattern of patterns) {
|
|
318
|
-
for (const v of extractScopeVars(pattern)) {
|
|
319
|
-
vars.add(v);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
return Array.from(vars);
|
|
323
|
-
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core - Snapshot chunk encoding helpers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const SYNC_SNAPSHOT_CHUNK_ENCODING = 'json-row-frame-v1';
|
|
6
|
+
export type SyncSnapshotChunkEncoding = typeof SYNC_SNAPSHOT_CHUNK_ENCODING;
|
|
7
|
+
|
|
8
|
+
export const SYNC_SNAPSHOT_CHUNK_COMPRESSION = 'gzip';
|
|
9
|
+
export type SyncSnapshotChunkCompression =
|
|
10
|
+
typeof SYNC_SNAPSHOT_CHUNK_COMPRESSION;
|
|
11
|
+
|
|
12
|
+
const SNAPSHOT_ROW_FRAME_MAGIC = new Uint8Array([0x53, 0x52, 0x46, 0x31]); // "SRF1"
|
|
13
|
+
const FRAME_LENGTH_BYTES = 4;
|
|
14
|
+
const MAX_FRAME_BYTE_LENGTH = 0xffff_ffff;
|
|
15
|
+
|
|
16
|
+
function normalizeRowJson(row: unknown): string {
|
|
17
|
+
const serialized = JSON.stringify(row);
|
|
18
|
+
return serialized === undefined ? 'null' : serialized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Encode rows as framed JSON bytes without the format header.
|
|
23
|
+
*/
|
|
24
|
+
export function encodeSnapshotRowFrames(rows: readonly unknown[]): Uint8Array {
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const payloads: Uint8Array[] = [];
|
|
27
|
+
let totalByteLength = 0;
|
|
28
|
+
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
const payload = encoder.encode(normalizeRowJson(row));
|
|
31
|
+
if (payload.length > MAX_FRAME_BYTE_LENGTH) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Snapshot row payload exceeds ${MAX_FRAME_BYTE_LENGTH} bytes`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
payloads.push(payload);
|
|
37
|
+
totalByteLength += FRAME_LENGTH_BYTES + payload.length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const encoded = new Uint8Array(totalByteLength);
|
|
41
|
+
const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.length);
|
|
42
|
+
let offset = 0;
|
|
43
|
+
for (const payload of payloads) {
|
|
44
|
+
view.setUint32(offset, payload.length, false);
|
|
45
|
+
offset += FRAME_LENGTH_BYTES;
|
|
46
|
+
encoded.set(payload, offset);
|
|
47
|
+
offset += payload.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return encoded;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Encode rows as framed JSON bytes with a format header.
|
|
55
|
+
*
|
|
56
|
+
* Format:
|
|
57
|
+
* - 4-byte magic header ("SRF1")
|
|
58
|
+
* - repeated frames of:
|
|
59
|
+
* - 4-byte big-endian payload byte length
|
|
60
|
+
* - UTF-8 JSON payload
|
|
61
|
+
*/
|
|
62
|
+
export function encodeSnapshotRows(rows: readonly unknown[]): Uint8Array {
|
|
63
|
+
const framedRows = encodeSnapshotRowFrames(rows);
|
|
64
|
+
const totalByteLength = SNAPSHOT_ROW_FRAME_MAGIC.length + framedRows.length;
|
|
65
|
+
|
|
66
|
+
const encoded = new Uint8Array(totalByteLength);
|
|
67
|
+
encoded.set(SNAPSHOT_ROW_FRAME_MAGIC, 0);
|
|
68
|
+
encoded.set(framedRows, SNAPSHOT_ROW_FRAME_MAGIC.length);
|
|
69
|
+
|
|
70
|
+
return encoded;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decode framed JSON bytes into rows.
|
|
75
|
+
*/
|
|
76
|
+
export function decodeSnapshotRows(bytes: Uint8Array): unknown[] {
|
|
77
|
+
if (bytes.length < SNAPSHOT_ROW_FRAME_MAGIC.length) {
|
|
78
|
+
throw new Error('Snapshot chunk payload is too small');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (let index = 0; index < SNAPSHOT_ROW_FRAME_MAGIC.length; index += 1) {
|
|
82
|
+
const expected = SNAPSHOT_ROW_FRAME_MAGIC[index];
|
|
83
|
+
const actual = bytes[index];
|
|
84
|
+
if (actual !== expected) {
|
|
85
|
+
throw new Error('Unexpected snapshot chunk format');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rows: unknown[] = [];
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
|
|
92
|
+
let offset = SNAPSHOT_ROW_FRAME_MAGIC.length;
|
|
93
|
+
|
|
94
|
+
while (offset < bytes.length) {
|
|
95
|
+
if (offset + FRAME_LENGTH_BYTES > bytes.length) {
|
|
96
|
+
throw new Error('Snapshot chunk payload ended mid-frame header');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const payloadLength = view.getUint32(offset, false);
|
|
100
|
+
offset += FRAME_LENGTH_BYTES;
|
|
101
|
+
|
|
102
|
+
if (offset + payloadLength > bytes.length) {
|
|
103
|
+
throw new Error('Snapshot chunk payload ended mid-frame body');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const payload = bytes.subarray(offset, offset + payloadLength);
|
|
107
|
+
offset += payloadLength;
|
|
108
|
+
rows.push(JSON.parse(decoder.decode(payload)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return rows;
|
|
112
|
+
}
|