@strata-sync/next 0.1.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/AGENTS.md +24 -0
- package/README.md +57 -0
- package/package.json +49 -0
- package/src/bootstrap.ts +624 -0
- package/src/client.ts +38 -0
- package/src/index.ts +45 -0
- package/src/prefetch.ts +155 -0
- package/src/provider.tsx +156 -0
- package/src/server.ts +29 -0
- package/tests/bootstrap.test.ts +377 -0
- package/tsconfig.json +28 -0
- package/vitest.config.ts +9 -0
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import type { StorageAdapter } from "@strata-sync/client";
|
|
2
|
+
import type {
|
|
3
|
+
BootstrapMetadata,
|
|
4
|
+
ModelRegistrySnapshot,
|
|
5
|
+
ModelRow,
|
|
6
|
+
SchemaDefinition,
|
|
7
|
+
} from "@strata-sync/core";
|
|
8
|
+
import {
|
|
9
|
+
computeSchemaHash,
|
|
10
|
+
getOrCreateClientId,
|
|
11
|
+
ModelRegistry,
|
|
12
|
+
} from "@strata-sync/core";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PREFETCH_TIMEOUT_MS = 10_000;
|
|
15
|
+
const NDJSON_ACCEPT_HEADER = "application/x-ndjson";
|
|
16
|
+
const TRAILING_SLASH_RE = /\/+$/;
|
|
17
|
+
const KNOWN_SYNC_SUFFIXES = ["/bootstrap", "/batch", "/deltas"];
|
|
18
|
+
|
|
19
|
+
export interface BootstrapSnapshot {
|
|
20
|
+
version: 1;
|
|
21
|
+
schemaHash: string;
|
|
22
|
+
lastSyncId: number;
|
|
23
|
+
firstSyncId?: number;
|
|
24
|
+
groups: string[];
|
|
25
|
+
rows: ModelRow[];
|
|
26
|
+
fetchedAt: number;
|
|
27
|
+
rowCount?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PrefetchBootstrapOptions {
|
|
31
|
+
endpoint: string;
|
|
32
|
+
authorization?: string;
|
|
33
|
+
headers?: Record<string, string>;
|
|
34
|
+
models?: string[];
|
|
35
|
+
groups?: string[];
|
|
36
|
+
schemaHash?: string;
|
|
37
|
+
timeout?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function prefetchBootstrap(
|
|
41
|
+
options: PrefetchBootstrapOptions
|
|
42
|
+
): Promise<BootstrapSnapshot> {
|
|
43
|
+
const {
|
|
44
|
+
endpoint,
|
|
45
|
+
authorization,
|
|
46
|
+
headers,
|
|
47
|
+
models,
|
|
48
|
+
groups,
|
|
49
|
+
schemaHash,
|
|
50
|
+
timeout = DEFAULT_PREFETCH_TIMEOUT_MS,
|
|
51
|
+
} = options;
|
|
52
|
+
|
|
53
|
+
const requestHeaders = buildRequestHeaders(authorization, headers);
|
|
54
|
+
const params = buildBootstrapParams({ models, groups, schemaHash });
|
|
55
|
+
const url = buildBootstrapUrl(endpoint, params);
|
|
56
|
+
|
|
57
|
+
const response = await fetchBootstrap(url, requestHeaders, timeout);
|
|
58
|
+
await ensureResponseOk(response);
|
|
59
|
+
|
|
60
|
+
const { rows, metadata, rowCount } = await readBootstrapStream(
|
|
61
|
+
getResponseBody(response)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const resolvedMetadata = ensureBootstrapMetadata(metadata);
|
|
65
|
+
const snapshotSchemaHash = resolveSnapshotSchemaHash(
|
|
66
|
+
resolvedMetadata,
|
|
67
|
+
schemaHash
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
version: 1,
|
|
72
|
+
schemaHash: snapshotSchemaHash,
|
|
73
|
+
lastSyncId: resolvedMetadata.lastSyncId,
|
|
74
|
+
firstSyncId: resolveFirstSyncId(resolvedMetadata),
|
|
75
|
+
groups: resolvedMetadata.subscribedSyncGroups,
|
|
76
|
+
rows,
|
|
77
|
+
fetchedAt: Date.now(),
|
|
78
|
+
rowCount,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildRequestHeaders(
|
|
83
|
+
authorization?: string,
|
|
84
|
+
headers?: Record<string, string>
|
|
85
|
+
): Record<string, string> {
|
|
86
|
+
const requestHeaders: Record<string, string> = {
|
|
87
|
+
Accept: NDJSON_ACCEPT_HEADER,
|
|
88
|
+
...headers,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (authorization) {
|
|
92
|
+
requestHeaders.Authorization = authorization;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return requestHeaders;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildBootstrapParams(options: {
|
|
99
|
+
models?: string[];
|
|
100
|
+
groups?: string[];
|
|
101
|
+
schemaHash?: string;
|
|
102
|
+
}): URLSearchParams {
|
|
103
|
+
const params = new URLSearchParams();
|
|
104
|
+
params.set("type", "full");
|
|
105
|
+
|
|
106
|
+
if (options.models?.length) {
|
|
107
|
+
params.set("onlyModels", options.models.join(","));
|
|
108
|
+
}
|
|
109
|
+
if (options.schemaHash) {
|
|
110
|
+
params.set("schemaHash", options.schemaHash);
|
|
111
|
+
}
|
|
112
|
+
if (options.groups?.length) {
|
|
113
|
+
params.set("syncGroups", options.groups.join(","));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return params;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildBootstrapUrl(endpoint: string, params: URLSearchParams): string {
|
|
120
|
+
const baseEndpoint = normalizeSyncEndpoint(endpoint);
|
|
121
|
+
const url = joinSyncUrl(baseEndpoint, "/bootstrap");
|
|
122
|
+
return `${url}?${params.toString()}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function fetchBootstrap(
|
|
126
|
+
url: string,
|
|
127
|
+
headers: Record<string, string>,
|
|
128
|
+
timeout: number
|
|
129
|
+
): Promise<Response> {
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
return await fetch(url, {
|
|
135
|
+
method: "GET",
|
|
136
|
+
headers,
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
});
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timeoutId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function ensureResponseOk(response: Response): Promise<void> {
|
|
145
|
+
if (response.ok) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const text = await response.text();
|
|
150
|
+
throw new Error(`Bootstrap prefetch failed: ${response.status} ${text}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getResponseBody(response: Response): ReadableStream<Uint8Array> {
|
|
154
|
+
if (!response.body) {
|
|
155
|
+
throw new Error("Bootstrap prefetch response has no body");
|
|
156
|
+
}
|
|
157
|
+
return response.body;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface BootstrapParseResult {
|
|
161
|
+
rows: ModelRow[];
|
|
162
|
+
metadata: BootstrapMetadata | null;
|
|
163
|
+
rowCount?: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface BootstrapParseState extends BootstrapParseResult {
|
|
167
|
+
buffer: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readBootstrapStream(
|
|
171
|
+
stream: ReadableStream<Uint8Array>
|
|
172
|
+
): Promise<BootstrapParseResult> {
|
|
173
|
+
const reader = stream.getReader();
|
|
174
|
+
const decoder = new TextDecoder();
|
|
175
|
+
const state: BootstrapParseState = {
|
|
176
|
+
rows: [],
|
|
177
|
+
metadata: null,
|
|
178
|
+
rowCount: undefined,
|
|
179
|
+
buffer: "",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
while (true) {
|
|
184
|
+
const { done, value } = await reader.read();
|
|
185
|
+
if (done) {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
state.buffer += decoder.decode(value, { stream: true });
|
|
190
|
+
state.buffer = drainBootstrapLines(state, state.buffer);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
flushBootstrapBuffer(state);
|
|
194
|
+
} finally {
|
|
195
|
+
reader.releaseLock();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
rows: state.rows,
|
|
200
|
+
metadata: state.metadata,
|
|
201
|
+
rowCount: state.rowCount,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function drainBootstrapLines(
|
|
206
|
+
state: BootstrapParseState,
|
|
207
|
+
buffer: string
|
|
208
|
+
): string {
|
|
209
|
+
const lines = buffer.split("\n");
|
|
210
|
+
const remaining = lines.pop() ?? "";
|
|
211
|
+
|
|
212
|
+
for (const line of lines) {
|
|
213
|
+
applyBootstrapLine(state, line);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return remaining;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function flushBootstrapBuffer(state: BootstrapParseState): void {
|
|
220
|
+
const trimmed = state.buffer.trim();
|
|
221
|
+
if (!trimmed) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
applyBootstrapLine(state, trimmed);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function applyBootstrapLine(state: BootstrapParseState, line: string): void {
|
|
228
|
+
const parsed = parseBootstrapLine(line);
|
|
229
|
+
if (!parsed) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (parsed.type === "meta") {
|
|
234
|
+
state.metadata = parsed.metadata;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (parsed.type === "row") {
|
|
239
|
+
state.rows.push(parsed.row);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
state.rowCount = parsed.rowCount;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface ValidatedBootstrapMetadata extends BootstrapMetadata {
|
|
247
|
+
lastSyncId: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function ensureBootstrapMetadata(
|
|
251
|
+
metadata: BootstrapMetadata | null
|
|
252
|
+
): ValidatedBootstrapMetadata {
|
|
253
|
+
if (!metadata) {
|
|
254
|
+
throw new Error("Bootstrap prefetch did not receive metadata");
|
|
255
|
+
}
|
|
256
|
+
if (metadata.lastSyncId === undefined) {
|
|
257
|
+
throw new Error("Bootstrap metadata is missing lastSyncId");
|
|
258
|
+
}
|
|
259
|
+
return metadata as ValidatedBootstrapMetadata;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveSnapshotSchemaHash(
|
|
263
|
+
metadata: BootstrapMetadata,
|
|
264
|
+
fallback?: string
|
|
265
|
+
): string {
|
|
266
|
+
return metadata.schemaHash ?? fallback ?? "";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function resolveFirstSyncId(metadata: ValidatedBootstrapMetadata): number {
|
|
270
|
+
return typeof metadata.raw?.firstSyncId === "number"
|
|
271
|
+
? metadata.raw.firstSyncId
|
|
272
|
+
: metadata.lastSyncId;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface BootstrapSnapshotPayload {
|
|
276
|
+
version: 1;
|
|
277
|
+
encoding: "json" | "gzip-base64";
|
|
278
|
+
data: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface SerializeBootstrapOptions {
|
|
282
|
+
compress?: boolean;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function serializeBootstrapSnapshot(
|
|
286
|
+
snapshot: BootstrapSnapshot,
|
|
287
|
+
options: SerializeBootstrapOptions = {}
|
|
288
|
+
): Promise<BootstrapSnapshotPayload> {
|
|
289
|
+
const json = JSON.stringify(snapshot);
|
|
290
|
+
const shouldCompress =
|
|
291
|
+
options.compress !== false && canUseCompressionStreams();
|
|
292
|
+
|
|
293
|
+
if (!shouldCompress) {
|
|
294
|
+
return {
|
|
295
|
+
version: snapshot.version,
|
|
296
|
+
encoding: "json",
|
|
297
|
+
data: json,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const compressed = await compressToBase64(json);
|
|
302
|
+
return {
|
|
303
|
+
version: snapshot.version,
|
|
304
|
+
encoding: "gzip-base64",
|
|
305
|
+
data: compressed,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function deserializeBootstrapSnapshot(
|
|
310
|
+
payload: BootstrapSnapshotPayload
|
|
311
|
+
): Promise<BootstrapSnapshot> {
|
|
312
|
+
if (payload.version !== 1) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Unsupported bootstrap payload version: ${payload.version}`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (payload.encoding === "json") {
|
|
319
|
+
return JSON.parse(payload.data) as BootstrapSnapshot;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!canUseCompressionStreams()) {
|
|
323
|
+
throw new Error("DecompressionStream is not available in this runtime");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const json = await decompressFromBase64(payload.data);
|
|
327
|
+
return JSON.parse(json) as BootstrapSnapshot;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function encodeBootstrapSnapshot(
|
|
331
|
+
snapshot: BootstrapSnapshot,
|
|
332
|
+
options: SerializeBootstrapOptions = {}
|
|
333
|
+
): Promise<string> {
|
|
334
|
+
const payload = await serializeBootstrapSnapshot(snapshot, options);
|
|
335
|
+
return JSON.stringify(payload);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function decodeBootstrapSnapshot(
|
|
339
|
+
encoded: string
|
|
340
|
+
): Promise<BootstrapSnapshot> {
|
|
341
|
+
const payload = JSON.parse(encoded) as BootstrapSnapshotPayload;
|
|
342
|
+
return deserializeBootstrapSnapshot(payload);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function isBootstrapSnapshotStale(
|
|
346
|
+
snapshot: BootstrapSnapshot,
|
|
347
|
+
maxAge = 30_000
|
|
348
|
+
): boolean {
|
|
349
|
+
return Date.now() - snapshot.fetchedAt > maxAge;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export interface SeedStorageOptions {
|
|
353
|
+
storage: StorageAdapter;
|
|
354
|
+
snapshot: BootstrapSnapshot | BootstrapSnapshotPayload | string;
|
|
355
|
+
dbName?: string;
|
|
356
|
+
clearExisting?: boolean;
|
|
357
|
+
validateSchemaHash?: boolean;
|
|
358
|
+
batchSize?: number;
|
|
359
|
+
closeAfter?: boolean;
|
|
360
|
+
schema?: SchemaDefinition | ModelRegistrySnapshot;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface SeedStorageResult {
|
|
364
|
+
applied: boolean;
|
|
365
|
+
rowCount: number;
|
|
366
|
+
reason?: "schema_mismatch";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function seedStorageFromBootstrap(
|
|
370
|
+
options: SeedStorageOptions
|
|
371
|
+
): Promise<SeedStorageResult> {
|
|
372
|
+
const {
|
|
373
|
+
storage,
|
|
374
|
+
snapshot,
|
|
375
|
+
dbName = "sync-db",
|
|
376
|
+
clearExisting = true,
|
|
377
|
+
validateSchemaHash = true,
|
|
378
|
+
batchSize = 500,
|
|
379
|
+
closeAfter = true,
|
|
380
|
+
schema,
|
|
381
|
+
} = options;
|
|
382
|
+
|
|
383
|
+
const resolvedSnapshot = await resolveSnapshot(snapshot);
|
|
384
|
+
const localSchemaHash = computeSchemaHash(schema ?? ModelRegistry.snapshot());
|
|
385
|
+
|
|
386
|
+
if (
|
|
387
|
+
validateSchemaHash &&
|
|
388
|
+
resolvedSnapshot.schemaHash &&
|
|
389
|
+
resolvedSnapshot.schemaHash !== localSchemaHash
|
|
390
|
+
) {
|
|
391
|
+
return { applied: false, rowCount: 0, reason: "schema_mismatch" };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let opened = false;
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
await storage.open({ name: dbName, schema });
|
|
398
|
+
opened = true;
|
|
399
|
+
|
|
400
|
+
const existingMeta = await storage.getMeta();
|
|
401
|
+
const clientId =
|
|
402
|
+
existingMeta.clientId || getOrCreateClientId(`${dbName}_client_id`);
|
|
403
|
+
|
|
404
|
+
if (clearExisting) {
|
|
405
|
+
await storage.clear();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let ops: Array<{
|
|
409
|
+
type: "put";
|
|
410
|
+
modelName: string;
|
|
411
|
+
data: Record<string, unknown>;
|
|
412
|
+
}> = [];
|
|
413
|
+
|
|
414
|
+
for (const row of resolvedSnapshot.rows) {
|
|
415
|
+
ops.push({ type: "put", modelName: row.modelName, data: row.data });
|
|
416
|
+
|
|
417
|
+
if (ops.length >= batchSize) {
|
|
418
|
+
await storage.writeBatch(ops);
|
|
419
|
+
ops = [];
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (ops.length > 0) {
|
|
424
|
+
await storage.writeBatch(ops);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
await storage.setMeta({
|
|
428
|
+
schemaHash: localSchemaHash,
|
|
429
|
+
lastSyncId: resolvedSnapshot.lastSyncId,
|
|
430
|
+
firstSyncId: resolvedSnapshot.firstSyncId,
|
|
431
|
+
subscribedSyncGroups: resolvedSnapshot.groups,
|
|
432
|
+
bootstrapComplete: true,
|
|
433
|
+
lastSyncAt: resolvedSnapshot.fetchedAt,
|
|
434
|
+
clientId,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return { applied: true, rowCount: resolvedSnapshot.rows.length };
|
|
438
|
+
} finally {
|
|
439
|
+
if (closeAfter && opened) {
|
|
440
|
+
await storage.close();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isPayload(
|
|
446
|
+
value: BootstrapSnapshot | BootstrapSnapshotPayload | string
|
|
447
|
+
): value is BootstrapSnapshotPayload {
|
|
448
|
+
return typeof value === "object" && value !== null && "encoding" in value;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function resolveSnapshot(
|
|
452
|
+
snapshot: BootstrapSnapshot | BootstrapSnapshotPayload | string
|
|
453
|
+
): Promise<BootstrapSnapshot> {
|
|
454
|
+
if (typeof snapshot === "string") {
|
|
455
|
+
const parsed = JSON.parse(snapshot) as BootstrapSnapshotPayload;
|
|
456
|
+
return deserializeBootstrapSnapshot(parsed);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (isPayload(snapshot)) {
|
|
460
|
+
return deserializeBootstrapSnapshot(snapshot);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return Promise.resolve(snapshot);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function canUseCompressionStreams(): boolean {
|
|
467
|
+
return (
|
|
468
|
+
typeof CompressionStream !== "undefined" &&
|
|
469
|
+
typeof DecompressionStream !== "undefined"
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function compressToBase64(input: string): Promise<string> {
|
|
474
|
+
const compressedStream = new Blob([input])
|
|
475
|
+
.stream()
|
|
476
|
+
.pipeThrough(new CompressionStream("gzip"));
|
|
477
|
+
const buffer = await new Response(compressedStream).arrayBuffer();
|
|
478
|
+
return toBase64(new Uint8Array(buffer));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function decompressFromBase64(encoded: string): Promise<string> {
|
|
482
|
+
const bytes = fromBase64(encoded);
|
|
483
|
+
const decompressedStream = new Blob([bytes as BlobPart])
|
|
484
|
+
.stream()
|
|
485
|
+
.pipeThrough(new DecompressionStream("gzip"));
|
|
486
|
+
return new Response(decompressedStream).text();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function toBase64(bytes: Uint8Array): string {
|
|
490
|
+
if (typeof Buffer !== "undefined") {
|
|
491
|
+
return Buffer.from(bytes).toString("base64");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let binary = "";
|
|
495
|
+
for (const byte of bytes) {
|
|
496
|
+
binary += String.fromCharCode(byte);
|
|
497
|
+
}
|
|
498
|
+
return btoa(binary);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function fromBase64(encoded: string): Uint8Array {
|
|
502
|
+
if (typeof Buffer !== "undefined") {
|
|
503
|
+
return new Uint8Array(Buffer.from(encoded, "base64"));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const binary = atob(encoded);
|
|
507
|
+
const bytes = new Uint8Array(binary.length);
|
|
508
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
509
|
+
bytes[i] = binary.charCodeAt(i);
|
|
510
|
+
}
|
|
511
|
+
return bytes;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
type ParsedBootstrapLine =
|
|
515
|
+
| { type: "meta"; metadata: BootstrapMetadata }
|
|
516
|
+
| { type: "row"; row: ModelRow }
|
|
517
|
+
| { type: "end"; rowCount?: number };
|
|
518
|
+
|
|
519
|
+
function parseBootstrapLine(line: string): ParsedBootstrapLine | null {
|
|
520
|
+
const trimmed = line.trim();
|
|
521
|
+
if (!trimmed) {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (trimmed.startsWith("_metadata_=")) {
|
|
526
|
+
const raw = JSON.parse(trimmed.slice("_metadata_=".length)) as Record<
|
|
527
|
+
string,
|
|
528
|
+
unknown
|
|
529
|
+
>;
|
|
530
|
+
return { type: "meta", metadata: normalizeBootstrapMetadata(raw) };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
534
|
+
|
|
535
|
+
if (typeof parsed._metadata_ === "object" && parsed._metadata_ !== null) {
|
|
536
|
+
return {
|
|
537
|
+
type: "meta",
|
|
538
|
+
metadata: normalizeBootstrapMetadata(
|
|
539
|
+
parsed._metadata_ as Record<string, unknown>
|
|
540
|
+
),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (isBootstrapMetadata(parsed)) {
|
|
545
|
+
return { type: "meta", metadata: normalizeBootstrapMetadata(parsed) };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (parsed.type === "end") {
|
|
549
|
+
return {
|
|
550
|
+
type: "end",
|
|
551
|
+
rowCount:
|
|
552
|
+
typeof parsed.rowCount === "number" ? parsed.rowCount : undefined,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (typeof parsed.__class !== "string") {
|
|
557
|
+
throw new Error("Bootstrap row is missing __class");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const { __class: modelName, ...data } = parsed;
|
|
561
|
+
return {
|
|
562
|
+
type: "row",
|
|
563
|
+
row: { modelName, data },
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function isBootstrapMetadata(parsed: Record<string, unknown>): boolean {
|
|
568
|
+
return (
|
|
569
|
+
"lastSyncId" in parsed ||
|
|
570
|
+
"subscribedSyncGroups" in parsed ||
|
|
571
|
+
"returnedModelsCount" in parsed
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function normalizeBootstrapMetadata(
|
|
576
|
+
parsed: Record<string, unknown>
|
|
577
|
+
): BootstrapMetadata {
|
|
578
|
+
const lastSyncIdRaw = parsed.lastSyncId;
|
|
579
|
+
const subscribedSyncGroupsRaw = parsed.subscribedSyncGroups;
|
|
580
|
+
|
|
581
|
+
const subscribedSyncGroups = Array.isArray(subscribedSyncGroupsRaw)
|
|
582
|
+
? subscribedSyncGroupsRaw.filter(
|
|
583
|
+
(group): group is string => typeof group === "string"
|
|
584
|
+
)
|
|
585
|
+
: [];
|
|
586
|
+
|
|
587
|
+
const result: BootstrapMetadata = {
|
|
588
|
+
subscribedSyncGroups,
|
|
589
|
+
returnedModelsCount:
|
|
590
|
+
parsed.returnedModelsCount &&
|
|
591
|
+
typeof parsed.returnedModelsCount === "object"
|
|
592
|
+
? (parsed.returnedModelsCount as Record<string, number>)
|
|
593
|
+
: undefined,
|
|
594
|
+
schemaHash:
|
|
595
|
+
typeof parsed.schemaHash === "string" ? parsed.schemaHash : undefined,
|
|
596
|
+
databaseVersion:
|
|
597
|
+
typeof parsed.databaseVersion === "number"
|
|
598
|
+
? parsed.databaseVersion
|
|
599
|
+
: undefined,
|
|
600
|
+
raw: parsed,
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
if (typeof lastSyncIdRaw === "string" || typeof lastSyncIdRaw === "number") {
|
|
604
|
+
result.lastSyncId = Number(lastSyncIdRaw);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return result;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function normalizeSyncEndpoint(endpoint: string): string {
|
|
611
|
+
const trimmed = endpoint.replace(TRAILING_SLASH_RE, "");
|
|
612
|
+
for (const suffix of KNOWN_SYNC_SUFFIXES) {
|
|
613
|
+
if (trimmed.endsWith(suffix)) {
|
|
614
|
+
return trimmed.slice(0, -suffix.length);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return trimmed;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function joinSyncUrl(base: string, path: string): string {
|
|
621
|
+
const normalizedBase = base.replace(TRAILING_SLASH_RE, "");
|
|
622
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
623
|
+
return `${normalizedBase}${normalizedPath}`;
|
|
624
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// biome-ignore lint/performance/noBarrelFile: This is the package's public client-side API entry point
|
|
4
|
+
export {
|
|
5
|
+
useConnectionState,
|
|
6
|
+
useIsOffline,
|
|
7
|
+
useModel,
|
|
8
|
+
useModelSuspense,
|
|
9
|
+
usePendingCount,
|
|
10
|
+
useQuery,
|
|
11
|
+
useQueryAll,
|
|
12
|
+
useQueryCount,
|
|
13
|
+
useSync,
|
|
14
|
+
useSyncClient,
|
|
15
|
+
useSyncClientInstance,
|
|
16
|
+
useSyncReady,
|
|
17
|
+
useSyncState,
|
|
18
|
+
} from "@strata-sync/react";
|
|
19
|
+
export type {
|
|
20
|
+
BootstrapSnapshot,
|
|
21
|
+
BootstrapSnapshotPayload,
|
|
22
|
+
SeedStorageOptions,
|
|
23
|
+
SeedStorageResult,
|
|
24
|
+
} from "./bootstrap";
|
|
25
|
+
export {
|
|
26
|
+
decodeBootstrapSnapshot,
|
|
27
|
+
deserializeBootstrapSnapshot,
|
|
28
|
+
isBootstrapSnapshotStale,
|
|
29
|
+
seedStorageFromBootstrap,
|
|
30
|
+
} from "./bootstrap";
|
|
31
|
+
export type {
|
|
32
|
+
PrefetchedData,
|
|
33
|
+
PrefetchOptions,
|
|
34
|
+
PrefetchResult,
|
|
35
|
+
} from "./prefetch";
|
|
36
|
+
export { deserializePrefetchResult, isPrefetchStale } from "./prefetch";
|
|
37
|
+
export type { NextSyncProviderProps } from "./provider";
|
|
38
|
+
export { NextSyncProvider, withSyncProvider } from "./provider";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// biome-ignore-all lint/performance/noBarrelFile: This is the package's main entry point
|
|
2
|
+
export type {
|
|
3
|
+
BootstrapSnapshot,
|
|
4
|
+
BootstrapSnapshotPayload,
|
|
5
|
+
NextSyncProviderProps,
|
|
6
|
+
PrefetchedData,
|
|
7
|
+
PrefetchOptions,
|
|
8
|
+
PrefetchResult,
|
|
9
|
+
SeedStorageOptions,
|
|
10
|
+
SeedStorageResult,
|
|
11
|
+
} from "./client";
|
|
12
|
+
export {
|
|
13
|
+
decodeBootstrapSnapshot,
|
|
14
|
+
deserializeBootstrapSnapshot,
|
|
15
|
+
deserializePrefetchResult,
|
|
16
|
+
isBootstrapSnapshotStale,
|
|
17
|
+
isPrefetchStale,
|
|
18
|
+
NextSyncProvider,
|
|
19
|
+
seedStorageFromBootstrap,
|
|
20
|
+
useConnectionState,
|
|
21
|
+
useIsOffline,
|
|
22
|
+
useModel,
|
|
23
|
+
useModelSuspense,
|
|
24
|
+
usePendingCount,
|
|
25
|
+
useQuery,
|
|
26
|
+
useQueryAll,
|
|
27
|
+
useQueryCount,
|
|
28
|
+
useSync,
|
|
29
|
+
useSyncClient,
|
|
30
|
+
useSyncClientInstance,
|
|
31
|
+
useSyncReady,
|
|
32
|
+
useSyncState,
|
|
33
|
+
withSyncProvider,
|
|
34
|
+
} from "./client";
|
|
35
|
+
export type {
|
|
36
|
+
PrefetchBootstrapOptions,
|
|
37
|
+
SerializeBootstrapOptions,
|
|
38
|
+
} from "./server";
|
|
39
|
+
export {
|
|
40
|
+
encodeBootstrapSnapshot,
|
|
41
|
+
prefetchBootstrap,
|
|
42
|
+
prefetchModels,
|
|
43
|
+
serializeBootstrapSnapshot,
|
|
44
|
+
serializePrefetchResult,
|
|
45
|
+
} from "./server";
|