@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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Prefetch utilities for Next.js Server Components
3
+ *
4
+ * Note: The sync engine is primarily designed for client-side usage.
5
+ * These utilities allow pre-fetching data on the server to pass as
6
+ * initial state to the client, reducing time-to-interactive.
7
+ */
8
+
9
+ export interface PrefetchedData {
10
+ modelName: string;
11
+ data: Record<string, unknown>[];
12
+ fetchedAt: number;
13
+ }
14
+
15
+ export interface PrefetchResult {
16
+ data: Map<string, PrefetchedData>;
17
+ success: boolean;
18
+ error?: string;
19
+ }
20
+
21
+ export interface PrefetchOptions {
22
+ endpoint: string;
23
+ authorization?: string;
24
+ /** Models to prefetch */
25
+ models: Array<{
26
+ name: string;
27
+ filter?: Record<string, unknown>;
28
+ limit?: number;
29
+ }>;
30
+ timeout?: number;
31
+ }
32
+
33
+ /**
34
+ * Prefetches model data on the server
35
+ *
36
+ * This can be used in Server Components to pre-fetch data
37
+ * that will be passed to the client as initial state.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * // app/dashboard/page.tsx
42
+ * import { prefetchModels } from '@strata-sync/next/server';
43
+ *
44
+ * export default async function DashboardPage() {
45
+ * const prefetchedData = await prefetchModels({
46
+ * endpoint: process.env.API_URL + '/sync/prefetch',
47
+ * authorization: await getServerAuth(),
48
+ * models: [
49
+ * { name: 'Project', limit: 10 },
50
+ * { name: 'Task', filter: { status: 'active' }, limit: 50 },
51
+ * ],
52
+ * });
53
+ *
54
+ * return (
55
+ * <SyncClientProvider initialData={prefetchedData}>
56
+ * <Dashboard />
57
+ * </SyncClientProvider>
58
+ * );
59
+ * }
60
+ * ```
61
+ */
62
+ export async function prefetchModels(
63
+ options: PrefetchOptions
64
+ ): Promise<PrefetchResult> {
65
+ const { endpoint, authorization, models, timeout = 5000 } = options;
66
+
67
+ const result: PrefetchResult = {
68
+ data: new Map(),
69
+ success: true,
70
+ };
71
+
72
+ try {
73
+ const controller = new AbortController();
74
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
75
+
76
+ const headers: Record<string, string> = {
77
+ "Content-Type": "application/json",
78
+ };
79
+
80
+ if (authorization) {
81
+ headers.Authorization = authorization;
82
+ }
83
+
84
+ const response = await fetch(endpoint, {
85
+ method: "POST",
86
+ headers,
87
+ body: JSON.stringify({ models }),
88
+ signal: controller.signal,
89
+ });
90
+
91
+ clearTimeout(timeoutId);
92
+
93
+ if (!response.ok) {
94
+ throw new Error(`Prefetch failed: ${response.status}`);
95
+ }
96
+
97
+ const json = (await response.json()) as {
98
+ data: Array<{
99
+ modelName: string;
100
+ rows: Record<string, unknown>[];
101
+ }>;
102
+ };
103
+
104
+ for (const item of json.data) {
105
+ result.data.set(item.modelName, {
106
+ modelName: item.modelName,
107
+ data: item.rows,
108
+ fetchedAt: Date.now(),
109
+ });
110
+ }
111
+ } catch (err) {
112
+ result.success = false;
113
+ result.error = err instanceof Error ? err.message : String(err);
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Serializes prefetch result for passing through Server Component boundary
121
+ */
122
+ export function serializePrefetchResult(result: PrefetchResult): string {
123
+ return JSON.stringify({
124
+ data: Array.from(result.data.entries()),
125
+ success: result.success,
126
+ error: result.error,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Deserializes prefetch result on the client
132
+ */
133
+ export function deserializePrefetchResult(serialized: string): PrefetchResult {
134
+ const parsed = JSON.parse(serialized) as {
135
+ data: [string, PrefetchedData][];
136
+ success: boolean;
137
+ error?: string;
138
+ };
139
+
140
+ return {
141
+ data: new Map(parsed.data),
142
+ success: parsed.success,
143
+ error: parsed.error,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Checks if prefetched data is stale
149
+ */
150
+ export function isPrefetchStale(
151
+ prefetched: PrefetchedData,
152
+ maxAge = 30_000
153
+ ): boolean {
154
+ return Date.now() - prefetched.fetchedAt > maxAge;
155
+ }
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ import type { SyncClient } from "@strata-sync/client";
4
+ import { SyncProvider as BaseSyncProvider } from "@strata-sync/react";
5
+ import { type ReactNode, useEffect, useState } from "react";
6
+
7
+ /**
8
+ * Props for the Next.js sync provider
9
+ */
10
+ export interface NextSyncProviderProps {
11
+ /** Sync client instance or factory function */
12
+ client: SyncClient | (() => SyncClient);
13
+ /** Children to render */
14
+ children: ReactNode;
15
+ /** Loading component to show while client initializes */
16
+ loading?: ReactNode;
17
+ /** Error component to show if initialization fails */
18
+ error?: (error: Error) => ReactNode;
19
+ /** Callback when client is ready */
20
+ onReady?: () => void;
21
+ /** Callback when an error occurs */
22
+ onError?: (error: Error) => void;
23
+ }
24
+
25
+ /**
26
+ * Next.js App Router compatible sync provider
27
+ *
28
+ * This provider:
29
+ * - Only renders on the client (uses 'use client' directive)
30
+ * - Handles client initialization lifecycle
31
+ * - Provides loading and error states
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * // app/providers.tsx
36
+ * 'use client';
37
+ *
38
+ * import { NextSyncProvider } from '@strata-sync/next';
39
+ * import { createSyncClient } from '@strata-sync/client';
40
+ * import { createIndexedDbStorage } from '@strata-sync/storage-idb';
41
+ * import { createGraphQLTransport } from '@strata-sync/transport-graphql';
42
+ * import { createMobXReactivity } from '@strata-sync/mobx';
43
+ * import { schema } from './schema';
44
+ *
45
+ * const client = createSyncClient({
46
+ * schema,
47
+ * storage: createIndexedDbStorage(),
48
+ * transport: createGraphQLTransport({
49
+ * endpoint: 'https://api.example.com/graphql',
50
+ * syncEndpoint: 'https://api.example.com/sync',
51
+ * wsEndpoint: 'wss://api.example.com/sync',
52
+ * auth: {
53
+ * getAccessToken: async () => 'token',
54
+ * },
55
+ * mutationBuilder: (tx) => ({
56
+ * mutation: `issueUpdate(id: "${tx.modelId}", input: $input) { syncId }`,
57
+ * variables: { input: tx.payload },
58
+ * variableTypes: { input: 'IssueUpdateInput!' },
59
+ * }),
60
+ * }),
61
+ * reactivity: createMobXReactivity(),
62
+ * });
63
+ *
64
+ * export function Providers({ children }: { children: React.ReactNode }) {
65
+ * return (
66
+ * <NextSyncProvider
67
+ * client={client}
68
+ * loading={<div>Loading...</div>}
69
+ * >
70
+ * {children}
71
+ * </NextSyncProvider>
72
+ * );
73
+ * }
74
+ * ```
75
+ */
76
+ export function NextSyncProvider({
77
+ client,
78
+ children,
79
+ loading = null,
80
+ error: errorComponent,
81
+ onReady,
82
+ onError,
83
+ }: NextSyncProviderProps): ReactNode {
84
+ const [syncClient, setSyncClient] = useState<SyncClient | null>(null);
85
+ const [isInitializing, setIsInitializing] = useState(true);
86
+ const [initError, setInitError] = useState<Error | null>(null);
87
+
88
+ useEffect(() => {
89
+ let mounted = true;
90
+
91
+ const initClient = async () => {
92
+ try {
93
+ const resolvedClient = typeof client === "function" ? client() : client;
94
+
95
+ await resolvedClient.start();
96
+
97
+ if (mounted) {
98
+ setSyncClient(resolvedClient);
99
+ setIsInitializing(false);
100
+ onReady?.();
101
+ }
102
+ } catch (err) {
103
+ if (mounted) {
104
+ const error = err instanceof Error ? err : new Error(String(err));
105
+ setInitError(error);
106
+ setIsInitializing(false);
107
+ onError?.(error);
108
+ }
109
+ }
110
+ };
111
+
112
+ initClient();
113
+
114
+ return () => {
115
+ mounted = false;
116
+ // Note: We don't stop the client on unmount as it might be reused
117
+ };
118
+ }, [client, onReady, onError]);
119
+
120
+ if (isInitializing) {
121
+ return <>{loading}</>;
122
+ }
123
+
124
+ if (initError) {
125
+ if (errorComponent) {
126
+ return <>{errorComponent(initError)}</>;
127
+ }
128
+ throw initError; // Let error boundary handle it
129
+ }
130
+
131
+ if (!syncClient) {
132
+ return null;
133
+ }
134
+
135
+ return (
136
+ <BaseSyncProvider autoStart={false} autoStop={false} client={syncClient}>
137
+ {children}
138
+ </BaseSyncProvider>
139
+ );
140
+ }
141
+
142
+ /**
143
+ * HOC to wrap a component with the sync provider
144
+ */
145
+ export function withSyncProvider<P extends object>(
146
+ Component: React.ComponentType<P>,
147
+ providerProps: Omit<NextSyncProviderProps, "children">
148
+ ): React.ComponentType<P> {
149
+ return function WrappedComponent(props: P) {
150
+ return (
151
+ <NextSyncProvider {...providerProps}>
152
+ <Component {...props} />
153
+ </NextSyncProvider>
154
+ );
155
+ };
156
+ }
package/src/server.ts ADDED
@@ -0,0 +1,29 @@
1
+ // biome-ignore-all lint/performance/noBarrelFile: This is the package's public server-side API entry point
2
+ export type {
3
+ BootstrapSnapshot,
4
+ BootstrapSnapshotPayload,
5
+ PrefetchBootstrapOptions,
6
+ SeedStorageOptions,
7
+ SeedStorageResult,
8
+ SerializeBootstrapOptions,
9
+ } from "./bootstrap";
10
+ export {
11
+ decodeBootstrapSnapshot,
12
+ deserializeBootstrapSnapshot,
13
+ encodeBootstrapSnapshot,
14
+ isBootstrapSnapshotStale,
15
+ prefetchBootstrap,
16
+ seedStorageFromBootstrap,
17
+ serializeBootstrapSnapshot,
18
+ } from "./bootstrap";
19
+ export type {
20
+ PrefetchedData,
21
+ PrefetchOptions,
22
+ PrefetchResult,
23
+ } from "./prefetch";
24
+ export {
25
+ deserializePrefetchResult,
26
+ isPrefetchStale,
27
+ prefetchModels,
28
+ serializePrefetchResult,
29
+ } from "./prefetch";
@@ -0,0 +1,377 @@
1
+ import type { StorageAdapter, StorageMeta } from "@strata-sync/client";
2
+ import type {
3
+ ModelRegistrySnapshot,
4
+ SchemaDefinition,
5
+ SyncAction,
6
+ Transaction,
7
+ } from "@strata-sync/core";
8
+ import { computeSchemaHash } from "@strata-sync/core";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+ import {
11
+ decodeBootstrapSnapshot,
12
+ deserializeBootstrapSnapshot,
13
+ encodeBootstrapSnapshot,
14
+ isBootstrapSnapshotStale,
15
+ prefetchBootstrap,
16
+ seedStorageFromBootstrap,
17
+ serializeBootstrapSnapshot,
18
+ } from "../src/bootstrap";
19
+
20
+ interface BatchOp {
21
+ type: "put" | "delete";
22
+ modelName: string;
23
+ id?: string;
24
+ data?: Record<string, unknown>;
25
+ }
26
+
27
+ class MemoryStorage implements StorageAdapter {
28
+ meta: StorageMeta;
29
+ opened = false;
30
+ closed = false;
31
+ cleared = false;
32
+ openOptions: {
33
+ name?: string;
34
+ userId?: string;
35
+ version?: number;
36
+ userVersion?: number;
37
+ schema?: SchemaDefinition | ModelRegistrySnapshot;
38
+ } | null = null;
39
+ batches: BatchOp[][] = [];
40
+
41
+ constructor(meta: StorageMeta) {
42
+ this.meta = meta;
43
+ }
44
+
45
+ open(options: {
46
+ name?: string;
47
+ userId?: string;
48
+ version?: number;
49
+ userVersion?: number;
50
+ schema?: SchemaDefinition | ModelRegistrySnapshot;
51
+ }): Promise<void> {
52
+ this.opened = true;
53
+ this.openOptions = options;
54
+ return Promise.resolve();
55
+ }
56
+
57
+ close(): Promise<void> {
58
+ this.closed = true;
59
+ return Promise.resolve();
60
+ }
61
+
62
+ get<T>(_modelName: string, _id: string): Promise<T | null> {
63
+ return Promise.reject(new Error("Not implemented"));
64
+ }
65
+
66
+ getAll<T>(_modelName: string): Promise<T[]> {
67
+ return Promise.reject(new Error("Not implemented"));
68
+ }
69
+
70
+ put<T extends Record<string, unknown>>(
71
+ _modelName: string,
72
+ _row: T
73
+ ): Promise<void> {
74
+ return Promise.reject(new Error("Not implemented"));
75
+ }
76
+
77
+ delete(_modelName: string, _id: string): Promise<void> {
78
+ return Promise.reject(new Error("Not implemented"));
79
+ }
80
+
81
+ getByIndex<T>(
82
+ _modelName: string,
83
+ _indexName: string,
84
+ _key: string
85
+ ): Promise<T[]> {
86
+ return Promise.reject(new Error("Not implemented"));
87
+ }
88
+
89
+ writeBatch(ops: BatchOp[]): Promise<void> {
90
+ this.batches.push(ops);
91
+ return Promise.resolve();
92
+ }
93
+
94
+ getMeta(): Promise<StorageMeta> {
95
+ return Promise.resolve(this.meta);
96
+ }
97
+
98
+ setMeta(meta: Partial<StorageMeta>): Promise<void> {
99
+ this.meta = { ...this.meta, ...meta };
100
+ return Promise.resolve();
101
+ }
102
+
103
+ getModelPersistence(
104
+ _modelName: string
105
+ ): ReturnType<StorageAdapter["getModelPersistence"]> {
106
+ return Promise.reject(new Error("Not implemented"));
107
+ }
108
+
109
+ setModelPersistence(_modelName: string, _persisted: boolean): Promise<void> {
110
+ return Promise.reject(new Error("Not implemented"));
111
+ }
112
+
113
+ getOutbox(): Promise<Transaction[]> {
114
+ return Promise.reject(new Error("Not implemented"));
115
+ }
116
+
117
+ addToOutbox(_tx: Transaction): Promise<void> {
118
+ return Promise.reject(new Error("Not implemented"));
119
+ }
120
+
121
+ removeFromOutbox(_clientTxId: string): Promise<void> {
122
+ return Promise.reject(new Error("Not implemented"));
123
+ }
124
+
125
+ updateOutboxTransaction(
126
+ _clientTxId: string,
127
+ _updates: Partial<Transaction>
128
+ ): Promise<void> {
129
+ return Promise.reject(new Error("Not implemented"));
130
+ }
131
+
132
+ hasPartialIndex(
133
+ _modelName: string,
134
+ _indexedKey: string,
135
+ _keyValue: string
136
+ ): Promise<boolean> {
137
+ return Promise.reject(new Error("Not implemented"));
138
+ }
139
+
140
+ setPartialIndex(
141
+ _modelName: string,
142
+ _indexedKey: string,
143
+ _keyValue: string
144
+ ): Promise<void> {
145
+ return Promise.reject(new Error("Not implemented"));
146
+ }
147
+
148
+ addSyncActions(_actions: SyncAction[]): Promise<void> {
149
+ return Promise.reject(new Error("Not implemented"));
150
+ }
151
+
152
+ getSyncActions(
153
+ _afterSyncId?: number,
154
+ _limit?: number
155
+ ): Promise<SyncAction[]> {
156
+ return Promise.reject(new Error("Not implemented"));
157
+ }
158
+
159
+ clearSyncActions(): Promise<void> {
160
+ return Promise.reject(new Error("Not implemented"));
161
+ }
162
+
163
+ clear(): Promise<void> {
164
+ this.cleared = true;
165
+ return Promise.resolve();
166
+ }
167
+
168
+ count(_modelName: string): Promise<number> {
169
+ return Promise.reject(new Error("Not implemented"));
170
+ }
171
+ }
172
+
173
+ const encoder = new TextEncoder();
174
+
175
+ function createNdjsonResponse(lines: string[], status = 200): Response {
176
+ const stream = new ReadableStream<Uint8Array>({
177
+ start(controller) {
178
+ for (const line of lines) {
179
+ controller.enqueue(encoder.encode(`${line}\n`));
180
+ }
181
+ controller.close();
182
+ },
183
+ });
184
+
185
+ return new Response(stream, { status });
186
+ }
187
+
188
+ function stubFetch(lines: string[], status = 200) {
189
+ const fetchMock = vi
190
+ .fn()
191
+ .mockResolvedValue(createNdjsonResponse(lines, status));
192
+ vi.stubGlobal("fetch", fetchMock);
193
+ return fetchMock;
194
+ }
195
+
196
+ afterEach(() => {
197
+ vi.useRealTimers();
198
+ vi.unstubAllGlobals();
199
+ });
200
+
201
+ describe("prefetchBootstrap", () => {
202
+ it("uses firstSyncId from metadata and normalizes lastSyncId", async () => {
203
+ const metadataLine = JSON.stringify({
204
+ _metadata_: {
205
+ lastSyncId: "101",
206
+ firstSyncId: 99,
207
+ subscribedSyncGroups: ["alpha"],
208
+ },
209
+ });
210
+
211
+ stubFetch([metadataLine]);
212
+
213
+ const snapshot = await prefetchBootstrap({
214
+ endpoint: "https://api.example.com/sync",
215
+ schemaHash: "schema-from-option",
216
+ });
217
+
218
+ expect(snapshot.lastSyncId).toBe(101);
219
+ expect(snapshot.firstSyncId).toBe(99);
220
+ expect(snapshot.schemaHash).toBe("schema-from-option");
221
+ });
222
+
223
+ it("throws when metadata is missing", async () => {
224
+ const rowLine = JSON.stringify({ __class: "Issue", id: "issue-1" });
225
+ stubFetch([rowLine]);
226
+
227
+ await expect(
228
+ prefetchBootstrap({ endpoint: "https://api.example.com/sync" })
229
+ ).rejects.toThrow("Bootstrap prefetch did not receive metadata");
230
+ });
231
+
232
+ it("normalizes sync endpoints and query params", async () => {
233
+ const rowLine = JSON.stringify({ __class: "Issue", id: "issue-1" });
234
+ const metadataLine = `_metadata_=${JSON.stringify({
235
+ lastSyncId: 1,
236
+ subscribedSyncGroups: [],
237
+ })}`;
238
+ const endLine = JSON.stringify({ type: "end", rowCount: 1 });
239
+
240
+ const endpoints = [
241
+ "https://api.example.com/sync",
242
+ "https://api.example.com/sync/bootstrap",
243
+ "https://api.example.com/sync/batch",
244
+ "https://api.example.com/sync/deltas",
245
+ ];
246
+
247
+ for (const endpoint of endpoints) {
248
+ const fetchMock = stubFetch([rowLine, metadataLine, endLine]);
249
+
250
+ await prefetchBootstrap({
251
+ endpoint,
252
+ models: ["Issue", "Project"],
253
+ groups: ["group-a", "group-b"],
254
+ schemaHash: "schema-hash",
255
+ });
256
+
257
+ const url = new URL(fetchMock.mock.calls[0][0] as string);
258
+ expect(url.pathname).toBe("/sync/bootstrap");
259
+ expect(url.searchParams.get("type")).toBe("full");
260
+ expect(url.searchParams.get("onlyModels")).toBe("Issue,Project");
261
+ expect(url.searchParams.get("syncGroups")).toBe("group-a,group-b");
262
+ expect(url.searchParams.get("schemaHash")).toBe("schema-hash");
263
+ }
264
+ });
265
+ });
266
+
267
+ describe("bootstrap snapshot utilities", () => {
268
+ it("roundtrips snapshot payloads without compression", async () => {
269
+ const snapshot = {
270
+ version: 1,
271
+ schemaHash: "schema-hash",
272
+ lastSyncId: 10,
273
+ firstSyncId: 5,
274
+ groups: ["group-a"],
275
+ rows: [{ modelName: "Issue", data: { id: "issue-1" } }],
276
+ fetchedAt: 1_700_000_000_000,
277
+ rowCount: 1,
278
+ };
279
+
280
+ const payload = await serializeBootstrapSnapshot(snapshot, {
281
+ compress: false,
282
+ });
283
+ expect(payload.encoding).toBe("json");
284
+
285
+ const parsed = await deserializeBootstrapSnapshot(payload);
286
+ expect(parsed).toEqual(snapshot);
287
+
288
+ const encoded = await encodeBootstrapSnapshot(snapshot, {
289
+ compress: false,
290
+ });
291
+ const decoded = await decodeBootstrapSnapshot(encoded);
292
+ expect(decoded).toEqual(snapshot);
293
+ });
294
+
295
+ it("flags stale snapshots using maxAge", () => {
296
+ vi.useFakeTimers();
297
+ vi.setSystemTime(new Date("2025-01-01T00:00:30.000Z"));
298
+
299
+ const snapshot = {
300
+ version: 1 as const,
301
+ schemaHash: "schema-hash",
302
+ lastSyncId: 10,
303
+ groups: [],
304
+ rows: [],
305
+ fetchedAt: Date.now() - 31_000,
306
+ };
307
+
308
+ expect(isBootstrapSnapshotStale(snapshot, 30_000)).toBe(true);
309
+ });
310
+ });
311
+
312
+ describe("seedStorageFromBootstrap", () => {
313
+ it("short-circuits on schema mismatch", async () => {
314
+ const schema: SchemaDefinition = { models: { Issue: { name: "Issue" } } };
315
+ const storage = new MemoryStorage({ lastSyncId: 0, clientId: "client-1" });
316
+
317
+ const result = await seedStorageFromBootstrap({
318
+ storage,
319
+ schema,
320
+ snapshot: {
321
+ version: 1,
322
+ schemaHash: "mismatched",
323
+ lastSyncId: 1,
324
+ groups: [],
325
+ rows: [],
326
+ fetchedAt: 1,
327
+ },
328
+ });
329
+
330
+ const localSchemaHash = computeSchemaHash(schema);
331
+ expect(localSchemaHash).not.toBe("mismatched");
332
+ expect(result).toEqual({
333
+ applied: false,
334
+ rowCount: 0,
335
+ reason: "schema_mismatch",
336
+ });
337
+ expect(storage.opened).toBe(false);
338
+ });
339
+
340
+ it("writes rows and metadata when schema matches", async () => {
341
+ const schema: SchemaDefinition = { models: { Issue: { name: "Issue" } } };
342
+ const schemaHash = computeSchemaHash(schema);
343
+ const storage = new MemoryStorage({ lastSyncId: 0, clientId: "client-1" });
344
+
345
+ const result = await seedStorageFromBootstrap({
346
+ storage,
347
+ schema,
348
+ batchSize: 2,
349
+ snapshot: {
350
+ version: 1,
351
+ schemaHash,
352
+ lastSyncId: 10,
353
+ firstSyncId: 4,
354
+ groups: ["group-a"],
355
+ rows: [
356
+ { modelName: "Issue", data: { id: "issue-1" } },
357
+ { modelName: "Issue", data: { id: "issue-2" } },
358
+ { modelName: "Issue", data: { id: "issue-3" } },
359
+ ],
360
+ fetchedAt: 1_700_000_000_000,
361
+ },
362
+ });
363
+
364
+ expect(result).toEqual({ applied: true, rowCount: 3 });
365
+ expect(storage.opened).toBe(true);
366
+ expect(storage.cleared).toBe(true);
367
+ expect(storage.batches.length).toBe(2);
368
+ expect(storage.meta.schemaHash).toBe(schemaHash);
369
+ expect(storage.meta.lastSyncId).toBe(10);
370
+ expect(storage.meta.firstSyncId).toBe(4);
371
+ expect(storage.meta.subscribedSyncGroups).toEqual(["group-a"]);
372
+ expect(storage.meta.bootstrapComplete).toBe(true);
373
+ expect(storage.meta.lastSyncAt).toBe(1_700_000_000_000);
374
+ expect(storage.meta.clientId).toBe("client-1");
375
+ expect(storage.closed).toBe(true);
376
+ });
377
+ });