@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/prefetch.ts
ADDED
|
@@ -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
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -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
|
+
});
|