@syncular/client 0.0.1-60
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/dist/blobs/index.d.ts +7 -0
- package/dist/blobs/index.d.ts.map +1 -0
- package/dist/blobs/index.js +7 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/blobs/manager.d.ts +345 -0
- package/dist/blobs/manager.d.ts.map +1 -0
- package/dist/blobs/manager.js +749 -0
- package/dist/blobs/manager.js.map +1 -0
- package/dist/blobs/migrate.d.ts +14 -0
- package/dist/blobs/migrate.d.ts.map +1 -0
- package/dist/blobs/migrate.js +59 -0
- package/dist/blobs/migrate.js.map +1 -0
- package/dist/blobs/types.d.ts +62 -0
- package/dist/blobs/types.d.ts.map +1 -0
- package/dist/blobs/types.js +5 -0
- package/dist/blobs/types.js.map +1 -0
- package/dist/client.d.ts +338 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +834 -0
- package/dist/client.js.map +1 -0
- package/dist/conflicts.d.ts +31 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +118 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/create-client.d.ts +115 -0
- package/dist/create-client.d.ts.map +1 -0
- package/dist/create-client.js +162 -0
- package/dist/create-client.js.map +1 -0
- package/dist/engine/SyncEngine.d.ts +215 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -0
- package/dist/engine/SyncEngine.js +1066 -0
- package/dist/engine/SyncEngine.js.map +1 -0
- package/dist/engine/index.d.ts +6 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +6 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/types.d.ts +230 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +7 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/handlers/create-handler.d.ts +110 -0
- package/dist/handlers/create-handler.d.ts.map +1 -0
- package/dist/handlers/create-handler.js +140 -0
- package/dist/handlers/create-handler.js.map +1 -0
- package/dist/handlers/registry.d.ts +15 -0
- package/dist/handlers/registry.d.ts.map +1 -0
- package/dist/handlers/registry.js +29 -0
- package/dist/handlers/registry.js.map +1 -0
- package/dist/handlers/types.d.ts +83 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +5 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +19 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +106 -0
- package/dist/migrate.js.map +1 -0
- package/dist/mutations.d.ts +138 -0
- package/dist/mutations.d.ts.map +1 -0
- package/dist/mutations.js +611 -0
- package/dist/mutations.js.map +1 -0
- package/dist/outbox.d.ts +112 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +304 -0
- package/dist/outbox.js.map +1 -0
- package/dist/plugins/incrementing-version.d.ts +34 -0
- package/dist/plugins/incrementing-version.d.ts.map +1 -0
- package/dist/plugins/incrementing-version.js +83 -0
- package/dist/plugins/incrementing-version.js.map +1 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +3 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/types.d.ts +49 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +15 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/proxy/connection.d.ts +33 -0
- package/dist/proxy/connection.d.ts.map +1 -0
- package/dist/proxy/connection.js +153 -0
- package/dist/proxy/connection.js.map +1 -0
- package/dist/proxy/dialect.d.ts +46 -0
- package/dist/proxy/dialect.d.ts.map +1 -0
- package/dist/proxy/dialect.js +58 -0
- package/dist/proxy/dialect.js.map +1 -0
- package/dist/proxy/driver.d.ts +42 -0
- package/dist/proxy/driver.d.ts.map +1 -0
- package/dist/proxy/driver.js +78 -0
- package/dist/proxy/driver.js.map +1 -0
- package/dist/proxy/index.d.ts +10 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +10 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/mutations.d.ts +9 -0
- package/dist/proxy/mutations.d.ts.map +1 -0
- package/dist/proxy/mutations.js +11 -0
- package/dist/proxy/mutations.js.map +1 -0
- package/dist/pull-engine.d.ts +45 -0
- package/dist/pull-engine.d.ts.map +1 -0
- package/dist/pull-engine.js +391 -0
- package/dist/pull-engine.js.map +1 -0
- package/dist/push-engine.d.ts +18 -0
- package/dist/push-engine.d.ts.map +1 -0
- package/dist/push-engine.js +155 -0
- package/dist/push-engine.js.map +1 -0
- package/dist/query/FingerprintCollector.d.ts +18 -0
- package/dist/query/FingerprintCollector.d.ts.map +1 -0
- package/dist/query/FingerprintCollector.js +28 -0
- package/dist/query/FingerprintCollector.js.map +1 -0
- package/dist/query/QueryContext.d.ts +33 -0
- package/dist/query/QueryContext.d.ts.map +1 -0
- package/dist/query/QueryContext.js +16 -0
- package/dist/query/QueryContext.js.map +1 -0
- package/dist/query/fingerprint.d.ts +61 -0
- package/dist/query/fingerprint.d.ts.map +1 -0
- package/dist/query/fingerprint.js +91 -0
- package/dist/query/fingerprint.js.map +1 -0
- package/dist/query/index.d.ts +7 -0
- package/dist/query/index.d.ts.map +1 -0
- package/dist/query/index.js +7 -0
- package/dist/query/index.js.map +1 -0
- package/dist/query/tracked-select.d.ts +18 -0
- package/dist/query/tracked-select.d.ts.map +1 -0
- package/dist/query/tracked-select.js +90 -0
- package/dist/query/tracked-select.js.map +1 -0
- package/dist/schema.d.ts +83 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +7 -0
- package/dist/schema.js.map +1 -0
- package/dist/sync-loop.d.ts +32 -0
- package/dist/sync-loop.d.ts.map +1 -0
- package/dist/sync-loop.js +249 -0
- package/dist/sync-loop.js.map +1 -0
- package/dist/utils/id.d.ts +8 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +19 -0
- package/dist/utils/id.js.map +1 -0
- package/package.json +58 -0
- package/src/blobs/index.ts +7 -0
- package/src/blobs/manager.ts +1027 -0
- package/src/blobs/migrate.ts +67 -0
- package/src/blobs/types.ts +84 -0
- package/src/client.ts +1222 -0
- package/src/conflicts.ts +180 -0
- package/src/create-client.ts +297 -0
- package/src/engine/SyncEngine.ts +1337 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/types.ts +268 -0
- package/src/handlers/create-handler.ts +287 -0
- package/src/handlers/registry.ts +36 -0
- package/src/handlers/types.ts +102 -0
- package/src/index.ts +25 -0
- package/src/migrate.ts +122 -0
- package/src/mutations.ts +926 -0
- package/src/outbox.ts +397 -0
- package/src/plugins/incrementing-version.ts +133 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/types.ts +63 -0
- package/src/proxy/connection.ts +191 -0
- package/src/proxy/dialect.ts +76 -0
- package/src/proxy/driver.ts +126 -0
- package/src/proxy/index.ts +10 -0
- package/src/proxy/mutations.ts +18 -0
- package/src/pull-engine.ts +518 -0
- package/src/push-engine.ts +201 -0
- package/src/query/FingerprintCollector.ts +29 -0
- package/src/query/QueryContext.ts +54 -0
- package/src/query/fingerprint.ts +109 -0
- package/src/query/index.ts +10 -0
- package/src/query/tracked-select.ts +139 -0
- package/src/schema.ts +94 -0
- package/src/sync-loop.ts +368 -0
- package/src/utils/id.ts +20 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/client - Sync engine types
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic types for the sync engine.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
SyncPullResponse,
|
|
9
|
+
SyncPushRequest,
|
|
10
|
+
SyncPushResponse,
|
|
11
|
+
SyncSubscriptionRequest,
|
|
12
|
+
SyncTransport,
|
|
13
|
+
} from '@syncular/core';
|
|
14
|
+
import type { Kysely } from 'kysely';
|
|
15
|
+
import type { ClientTableRegistry } from '../handlers/registry';
|
|
16
|
+
import type { SyncClientPlugin } from '../plugins/types';
|
|
17
|
+
import type { SyncClientDb } from '../schema';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Connection state for the sync engine
|
|
21
|
+
*/
|
|
22
|
+
export type SyncConnectionState =
|
|
23
|
+
| 'disconnected'
|
|
24
|
+
| 'connecting'
|
|
25
|
+
| 'connected'
|
|
26
|
+
| 'reconnecting';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Transport mode (detected or configured)
|
|
30
|
+
*/
|
|
31
|
+
export type SyncTransportMode = 'polling' | 'realtime';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sync engine state
|
|
35
|
+
*/
|
|
36
|
+
export interface SyncEngineState {
|
|
37
|
+
/** Whether sync is enabled */
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
/** Whether currently syncing */
|
|
40
|
+
isSyncing: boolean;
|
|
41
|
+
/** Current connection state */
|
|
42
|
+
connectionState: SyncConnectionState;
|
|
43
|
+
/** Transport mode */
|
|
44
|
+
transportMode: SyncTransportMode;
|
|
45
|
+
/** Last successful sync timestamp */
|
|
46
|
+
lastSyncAt: number | null;
|
|
47
|
+
/** Last error (cleared on successful sync) */
|
|
48
|
+
error: SyncError | null;
|
|
49
|
+
/** Number of pending outbox commits */
|
|
50
|
+
pendingCount: number;
|
|
51
|
+
/** Number of sync retries (reset on success) */
|
|
52
|
+
retryCount: number;
|
|
53
|
+
/** Whether currently retrying */
|
|
54
|
+
isRetrying: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sync error with context
|
|
59
|
+
*/
|
|
60
|
+
export interface SyncError {
|
|
61
|
+
/** Error code */
|
|
62
|
+
code: 'NETWORK_ERROR' | 'SYNC_ERROR' | 'CONFLICT' | 'UNKNOWN';
|
|
63
|
+
/** Error message */
|
|
64
|
+
message: string;
|
|
65
|
+
/** Original error if available */
|
|
66
|
+
cause?: Error;
|
|
67
|
+
/** Timestamp when error occurred */
|
|
68
|
+
timestamp: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sync event types
|
|
73
|
+
*/
|
|
74
|
+
export type SyncEventType =
|
|
75
|
+
| 'state:change'
|
|
76
|
+
| 'sync:start'
|
|
77
|
+
| 'sync:complete'
|
|
78
|
+
| 'sync:error'
|
|
79
|
+
| 'connection:change'
|
|
80
|
+
| 'outbox:change'
|
|
81
|
+
| 'data:change'
|
|
82
|
+
| 'presence:change';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Presence entry for a client connected to a scope
|
|
86
|
+
*/
|
|
87
|
+
export interface PresenceEntry<TMetadata = Record<string, unknown>> {
|
|
88
|
+
clientId: string;
|
|
89
|
+
actorId: string;
|
|
90
|
+
joinedAt: number;
|
|
91
|
+
metadata?: TMetadata;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sync event payloads
|
|
96
|
+
*/
|
|
97
|
+
export interface SyncEventPayloads {
|
|
98
|
+
'state:change': Record<string, never>;
|
|
99
|
+
'sync:start': { timestamp: number };
|
|
100
|
+
'sync:complete': {
|
|
101
|
+
timestamp: number;
|
|
102
|
+
pushedCommits: number;
|
|
103
|
+
pullRounds: number;
|
|
104
|
+
pullResponse: SyncPullResponse;
|
|
105
|
+
};
|
|
106
|
+
'sync:error': SyncError;
|
|
107
|
+
'connection:change': {
|
|
108
|
+
previous: SyncConnectionState;
|
|
109
|
+
current: SyncConnectionState;
|
|
110
|
+
};
|
|
111
|
+
'outbox:change': {
|
|
112
|
+
pendingCount: number;
|
|
113
|
+
sendingCount: number;
|
|
114
|
+
failedCount: number;
|
|
115
|
+
ackedCount: number;
|
|
116
|
+
};
|
|
117
|
+
'data:change': {
|
|
118
|
+
scopes: string[];
|
|
119
|
+
timestamp: number;
|
|
120
|
+
};
|
|
121
|
+
'presence:change': {
|
|
122
|
+
scopeKey: string;
|
|
123
|
+
presence: PresenceEntry[];
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Sync event listener
|
|
129
|
+
*/
|
|
130
|
+
export type SyncEventListener<T extends SyncEventType> = (
|
|
131
|
+
payload: SyncEventPayloads[T]
|
|
132
|
+
) => void;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sync engine configuration
|
|
136
|
+
*/
|
|
137
|
+
export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
138
|
+
/** Database instance */
|
|
139
|
+
db: Kysely<DB>;
|
|
140
|
+
/** Sync transport */
|
|
141
|
+
transport: SyncTransport;
|
|
142
|
+
/** Client shape registry */
|
|
143
|
+
shapes: ClientTableRegistry<DB>;
|
|
144
|
+
/** Actor id for sync scoping (null/undefined disables sync) */
|
|
145
|
+
actorId: string | null | undefined;
|
|
146
|
+
/** Stable device/app installation id */
|
|
147
|
+
clientId: string | null | undefined;
|
|
148
|
+
/** Subscriptions for partial sync */
|
|
149
|
+
subscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
|
|
150
|
+
/** Pull limit (commit count per request) */
|
|
151
|
+
limitCommits?: number;
|
|
152
|
+
/** Bootstrap snapshot rows per page */
|
|
153
|
+
limitSnapshotRows?: number;
|
|
154
|
+
/** Bootstrap snapshot pages per pull */
|
|
155
|
+
maxSnapshotPages?: number;
|
|
156
|
+
/** Optional state row id (multi-profile support) */
|
|
157
|
+
stateId?: string;
|
|
158
|
+
/** Poll interval in milliseconds (polling mode) */
|
|
159
|
+
pollIntervalMs?: number;
|
|
160
|
+
/** Max retries before giving up */
|
|
161
|
+
maxRetries?: number;
|
|
162
|
+
/** Migration function to run before first sync */
|
|
163
|
+
migrate?: (db: Kysely<DB>) => Promise<void>;
|
|
164
|
+
/** Called when migration fails. Receives the error. */
|
|
165
|
+
onMigrationError?: (error: Error) => void;
|
|
166
|
+
/**
|
|
167
|
+
* Enable realtime mode (WebSocket wake-ups).
|
|
168
|
+
* Default behavior is auto-enable when transport supports realtime.
|
|
169
|
+
* Set to false to force polling.
|
|
170
|
+
*/
|
|
171
|
+
realtimeEnabled?: boolean;
|
|
172
|
+
/** Fallback poll interval when realtime reconnecting */
|
|
173
|
+
realtimeFallbackPollMs?: number;
|
|
174
|
+
/** Error callback */
|
|
175
|
+
onError?: (error: SyncError) => void;
|
|
176
|
+
/** Conflict callback */
|
|
177
|
+
onConflict?: (conflict: ConflictInfo) => void;
|
|
178
|
+
/** Data change callback */
|
|
179
|
+
onDataChange?: (scopes: string[]) => void;
|
|
180
|
+
/** Optional client plugins (e.g. encryption) */
|
|
181
|
+
plugins?: SyncClientPlugin[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Conflict information for callback
|
|
186
|
+
*/
|
|
187
|
+
export interface ConflictInfo {
|
|
188
|
+
id: string;
|
|
189
|
+
outboxCommitId: string;
|
|
190
|
+
clientCommitId: string;
|
|
191
|
+
opIndex: number;
|
|
192
|
+
resultStatus: 'conflict' | 'error';
|
|
193
|
+
message: string;
|
|
194
|
+
code: string | null;
|
|
195
|
+
serverVersion: number | null;
|
|
196
|
+
serverRowJson: string | null;
|
|
197
|
+
createdAt: number;
|
|
198
|
+
/** Table name from the conflicting operation */
|
|
199
|
+
table: string;
|
|
200
|
+
/** Row ID from the conflicting operation */
|
|
201
|
+
rowId: string;
|
|
202
|
+
/** Local payload that was rejected (extracted from outbox) */
|
|
203
|
+
localPayload: Record<string, unknown> | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Realtime transport interface (duck-typed from transport)
|
|
208
|
+
*/
|
|
209
|
+
export interface RealtimeTransportLike extends SyncTransport {
|
|
210
|
+
connect(
|
|
211
|
+
args: { clientId: string },
|
|
212
|
+
onEvent: (event: {
|
|
213
|
+
event: string;
|
|
214
|
+
data: {
|
|
215
|
+
cursor?: number;
|
|
216
|
+
changes?: unknown[];
|
|
217
|
+
error?: string;
|
|
218
|
+
timestamp: number;
|
|
219
|
+
};
|
|
220
|
+
}) => void,
|
|
221
|
+
onStateChange?: (state: 'disconnected' | 'connecting' | 'connected') => void
|
|
222
|
+
): () => void;
|
|
223
|
+
getConnectionState(): 'disconnected' | 'connecting' | 'connected';
|
|
224
|
+
reconnect(): void;
|
|
225
|
+
sendPresenceJoin?(scopeKey: string, metadata?: Record<string, unknown>): void;
|
|
226
|
+
sendPresenceLeave?(scopeKey: string): void;
|
|
227
|
+
sendPresenceUpdate?(
|
|
228
|
+
scopeKey: string,
|
|
229
|
+
metadata: Record<string, unknown>
|
|
230
|
+
): void;
|
|
231
|
+
onPresenceEvent?(
|
|
232
|
+
callback: (event: {
|
|
233
|
+
action: 'join' | 'leave' | 'update' | 'snapshot';
|
|
234
|
+
scopeKey: string;
|
|
235
|
+
clientId?: string;
|
|
236
|
+
actorId?: string;
|
|
237
|
+
metadata?: Record<string, unknown>;
|
|
238
|
+
entries?: PresenceEntry[];
|
|
239
|
+
}) => void
|
|
240
|
+
): () => void;
|
|
241
|
+
/**
|
|
242
|
+
* Push a commit via WebSocket (bypasses HTTP).
|
|
243
|
+
* Returns `null` if WS is not connected or times out (caller should fall back to HTTP).
|
|
244
|
+
*/
|
|
245
|
+
pushViaWs?(request: SyncPushRequest): Promise<SyncPushResponse | null>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Sync result from a single sync cycle
|
|
250
|
+
*/
|
|
251
|
+
export interface SyncResult {
|
|
252
|
+
success: boolean;
|
|
253
|
+
pushedCommits: number;
|
|
254
|
+
pullRounds: number;
|
|
255
|
+
pullResponse: SyncPullResponse;
|
|
256
|
+
error?: SyncError;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Outbox statistics
|
|
261
|
+
*/
|
|
262
|
+
export interface OutboxStats {
|
|
263
|
+
pending: number;
|
|
264
|
+
sending: number;
|
|
265
|
+
failed: number;
|
|
266
|
+
acked: number;
|
|
267
|
+
total: number;
|
|
268
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/client - Declarative client handler helper
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ScopeDefinition, SyncChange, SyncSnapshot } from '@syncular/core';
|
|
6
|
+
import { normalizeScopes } from '@syncular/core';
|
|
7
|
+
import { sql } from 'kysely';
|
|
8
|
+
import type { SyncClientDb } from '../schema';
|
|
9
|
+
import type {
|
|
10
|
+
ClientClearContext,
|
|
11
|
+
ClientHandlerContext,
|
|
12
|
+
ClientSnapshotHookContext,
|
|
13
|
+
ClientTableHandler,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Coerce a value for SQL parameter binding.
|
|
22
|
+
* - PostgreSQL (PGlite) does not implicitly cast booleans to integers,
|
|
23
|
+
* so we convert them to 0/1 before binding.
|
|
24
|
+
* - Objects/arrays (e.g. from SerializePlugin auto-deserialization) are
|
|
25
|
+
* re-serialized to JSON strings so they survive the round-trip through
|
|
26
|
+
* SQLite/PGlite TEXT columns.
|
|
27
|
+
*/
|
|
28
|
+
function coerceForSql(value: unknown): unknown {
|
|
29
|
+
if (value === undefined) return null;
|
|
30
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
31
|
+
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
|
32
|
+
return JSON.stringify(value);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for creating a declarative client handler.
|
|
39
|
+
*/
|
|
40
|
+
export interface CreateClientHandlerOptions<
|
|
41
|
+
DB extends SyncClientDb,
|
|
42
|
+
TableName extends keyof DB & string,
|
|
43
|
+
> {
|
|
44
|
+
/** Table name in the database */
|
|
45
|
+
table: TableName;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Scope definitions for this table.
|
|
49
|
+
* Can be simple strings (column auto-derived) or objects with explicit mapping.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* // Simple: column auto-derived from placeholder
|
|
54
|
+
* scopes: ['user:{user_id}', 'org:{org_id}']
|
|
55
|
+
*
|
|
56
|
+
* // Explicit: when column differs from pattern variable
|
|
57
|
+
* scopes: [
|
|
58
|
+
* { pattern: 'user:{user_id}', column: 'owner_id' }
|
|
59
|
+
* ]
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
scopes: ScopeDefinition[];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Subscription configuration for this table.
|
|
66
|
+
* - `true` (default): Subscribe to this table with default scopes/params
|
|
67
|
+
* - `false`: Don't subscribe (handler only for local mutations)
|
|
68
|
+
* - Object: Subscribe with custom scopes and params
|
|
69
|
+
*/
|
|
70
|
+
subscribe?:
|
|
71
|
+
| boolean
|
|
72
|
+
| {
|
|
73
|
+
scopes?: Record<string, string | string[]>;
|
|
74
|
+
params?: Record<string, unknown>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Primary key column name (default: 'id') */
|
|
78
|
+
primaryKey?: keyof DB[TableName] & string;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Optional version column name (e.g. 'server_version') to store `change.row_version`.
|
|
82
|
+
* If omitted, row_version is ignored by the default handler.
|
|
83
|
+
*/
|
|
84
|
+
versionColumn?: keyof DB[TableName] & string;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Override: Apply a snapshot.
|
|
88
|
+
* Default: upsert all rows (no delete on isFirstPage).
|
|
89
|
+
*/
|
|
90
|
+
applySnapshot?: (
|
|
91
|
+
ctx: ClientHandlerContext<DB>,
|
|
92
|
+
snapshot: SyncSnapshot
|
|
93
|
+
) => Promise<void>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Override: Apply a single change.
|
|
97
|
+
* Default: upsert on upsert, delete on delete.
|
|
98
|
+
*/
|
|
99
|
+
applyChange?: (
|
|
100
|
+
ctx: ClientHandlerContext<DB>,
|
|
101
|
+
change: SyncChange
|
|
102
|
+
) => Promise<void>;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Override: Clear all data for this table.
|
|
106
|
+
* Default: delete all rows from the table.
|
|
107
|
+
*/
|
|
108
|
+
clearAll?: (ctx: ClientClearContext<DB>) => Promise<void>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Hook: Called when a snapshot begins (isFirstPage = true).
|
|
112
|
+
* Default: no-op.
|
|
113
|
+
*/
|
|
114
|
+
onSnapshotStart?: (ctx: ClientSnapshotHookContext<DB>) => Promise<void>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Hook: Called when a snapshot ends (isLastPage = true).
|
|
118
|
+
* Default: no-op.
|
|
119
|
+
*/
|
|
120
|
+
onSnapshotEnd?: (ctx: ClientSnapshotHookContext<DB>) => Promise<void>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create a declarative client table handler with sensible defaults.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```typescript
|
|
128
|
+
* import { createClientHandler } from '@syncular/client';
|
|
129
|
+
* import type { ClientDb } from './db.generated';
|
|
130
|
+
*
|
|
131
|
+
* export const tasksHandler = createClientHandler<ClientDb, 'tasks'>({
|
|
132
|
+
* table: 'tasks',
|
|
133
|
+
* scopes: ['user:{user_id}'], // column auto-derived from placeholder
|
|
134
|
+
* });
|
|
135
|
+
*
|
|
136
|
+
* // With custom column mapping:
|
|
137
|
+
* export const tasksHandler = createClientHandler<ClientDb, 'tasks'>({
|
|
138
|
+
* table: 'tasks',
|
|
139
|
+
* scopes: [{ pattern: 'user:{user_id}', column: 'owner_id' }],
|
|
140
|
+
* });
|
|
141
|
+
*
|
|
142
|
+
* // With soft delete pattern:
|
|
143
|
+
* export const tasksHandler = createClientHandler<ClientDb, 'tasks'>({
|
|
144
|
+
* table: 'tasks',
|
|
145
|
+
* scopes: ['user:{user_id}'],
|
|
146
|
+
* onSnapshotStart: async (ctx) => {
|
|
147
|
+
* await ctx.trx.updateTable('tasks')
|
|
148
|
+
* .set({ _sync_stale: 1 })
|
|
149
|
+
* .where('user_id', '=', ctx.scopeKey.split(':')[1])
|
|
150
|
+
* .execute();
|
|
151
|
+
* },
|
|
152
|
+
* onSnapshotEnd: async (ctx) => {
|
|
153
|
+
* await ctx.trx.deleteFrom('tasks')
|
|
154
|
+
* .where('_sync_stale', '=', 1)
|
|
155
|
+
* .execute();
|
|
156
|
+
* },
|
|
157
|
+
* });
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
export function createClientHandler<
|
|
161
|
+
DB extends SyncClientDb,
|
|
162
|
+
TableName extends keyof DB & string,
|
|
163
|
+
>(
|
|
164
|
+
options: CreateClientHandlerOptions<DB, TableName>
|
|
165
|
+
): ClientTableHandler<DB, TableName> {
|
|
166
|
+
const { table, scopes: scopeDefs } = options;
|
|
167
|
+
const primaryKey =
|
|
168
|
+
options.primaryKey ?? ('id' as keyof DB[TableName] & string);
|
|
169
|
+
const versionColumn = options.versionColumn;
|
|
170
|
+
|
|
171
|
+
// Normalize scopes to pattern map (stored for metadata)
|
|
172
|
+
const scopeColumnMap = normalizeScopes(scopeDefs);
|
|
173
|
+
const scopePatterns = Object.keys(scopeColumnMap);
|
|
174
|
+
|
|
175
|
+
// Default applySnapshot: upsert all rows
|
|
176
|
+
const defaultApplySnapshot = async (
|
|
177
|
+
ctx: ClientHandlerContext<DB>,
|
|
178
|
+
snapshot: SyncSnapshot
|
|
179
|
+
): Promise<void> => {
|
|
180
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
181
|
+
for (const row of snapshot.rows ?? []) {
|
|
182
|
+
if (!isRecord(row)) continue;
|
|
183
|
+
rows.push(row);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (rows.length === 0) return;
|
|
187
|
+
|
|
188
|
+
// Get column names from first row
|
|
189
|
+
const columns = Object.keys(rows[0]!);
|
|
190
|
+
if (columns.length === 0) return;
|
|
191
|
+
const updateColumns = columns.filter((c) => c !== primaryKey);
|
|
192
|
+
|
|
193
|
+
const onConflict =
|
|
194
|
+
updateColumns.length === 0
|
|
195
|
+
? sql`do nothing`
|
|
196
|
+
: sql`do update set ${sql.join(
|
|
197
|
+
updateColumns.map(
|
|
198
|
+
(col) => sql`${sql.ref(col)} = ${sql.ref(`excluded.${col}`)}`
|
|
199
|
+
),
|
|
200
|
+
sql`, `
|
|
201
|
+
)}`;
|
|
202
|
+
|
|
203
|
+
await sql`
|
|
204
|
+
insert into ${sql.table(table)} (${sql.join(columns.map((c) => sql.ref(c)))})
|
|
205
|
+
values ${sql.join(
|
|
206
|
+
rows.map(
|
|
207
|
+
(row) =>
|
|
208
|
+
sql`(${sql.join(
|
|
209
|
+
columns.map((col) => sql.val(coerceForSql(row[col]))),
|
|
210
|
+
sql`, `
|
|
211
|
+
)})`
|
|
212
|
+
),
|
|
213
|
+
sql`, `
|
|
214
|
+
)}
|
|
215
|
+
on conflict (${sql.ref(primaryKey)}) ${onConflict}
|
|
216
|
+
`.execute(ctx.trx);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Default applyChange: upsert on upsert, delete on delete
|
|
220
|
+
const defaultApplyChange = async (
|
|
221
|
+
ctx: ClientHandlerContext<DB>,
|
|
222
|
+
change: SyncChange
|
|
223
|
+
): Promise<void> => {
|
|
224
|
+
if (change.op === 'delete') {
|
|
225
|
+
await sql`
|
|
226
|
+
delete from ${sql.table(table)}
|
|
227
|
+
where ${sql.ref(primaryKey)} = ${sql.val(change.row_id)}
|
|
228
|
+
`.execute(ctx.trx);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const row = isRecord(change.row_json) ? change.row_json : {};
|
|
233
|
+
const insertRow: Record<string, unknown> = {
|
|
234
|
+
...row,
|
|
235
|
+
[primaryKey]: change.row_id,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
if (
|
|
239
|
+
versionColumn &&
|
|
240
|
+
change.row_version !== null &&
|
|
241
|
+
change.row_version !== undefined
|
|
242
|
+
) {
|
|
243
|
+
insertRow[versionColumn] = change.row_version;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const columns = Object.keys(insertRow);
|
|
247
|
+
const updateColumns = columns.filter((c) => c !== primaryKey);
|
|
248
|
+
const onConflict =
|
|
249
|
+
updateColumns.length === 0
|
|
250
|
+
? sql`do nothing`
|
|
251
|
+
: sql`do update set ${sql.join(
|
|
252
|
+
updateColumns.map(
|
|
253
|
+
(col) => sql`${sql.ref(col)} = ${sql.ref(`excluded.${col}`)}`
|
|
254
|
+
),
|
|
255
|
+
sql`, `
|
|
256
|
+
)}`;
|
|
257
|
+
|
|
258
|
+
await sql`
|
|
259
|
+
insert into ${sql.table(table)} (${sql.join(columns.map((c) => sql.ref(c)))})
|
|
260
|
+
values (${sql.join(
|
|
261
|
+
columns.map((col) => sql.val(coerceForSql(insertRow[col]))),
|
|
262
|
+
sql`, `
|
|
263
|
+
)})
|
|
264
|
+
on conflict (${sql.ref(primaryKey)}) ${onConflict}
|
|
265
|
+
`.execute(ctx.trx);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Default clearAll: delete all rows from the table
|
|
269
|
+
const defaultClearAll = async (
|
|
270
|
+
ctx: ClientClearContext<DB>
|
|
271
|
+
): Promise<void> => {
|
|
272
|
+
await sql`delete from ${sql.table(table)}`.execute(ctx.trx);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
table,
|
|
277
|
+
scopePatterns,
|
|
278
|
+
subscribe: options.subscribe,
|
|
279
|
+
|
|
280
|
+
applySnapshot: options.applySnapshot ?? defaultApplySnapshot,
|
|
281
|
+
applyChange: options.applyChange ?? defaultApplyChange,
|
|
282
|
+
clearAll: options.clearAll ?? defaultClearAll,
|
|
283
|
+
|
|
284
|
+
onSnapshotStart: options.onSnapshotStart,
|
|
285
|
+
onSnapshotEnd: options.onSnapshotEnd,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/client - Sync client table registry
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ClientTableHandler } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registry for client-side table handlers.
|
|
9
|
+
*/
|
|
10
|
+
export class ClientTableRegistry<DB> {
|
|
11
|
+
private handlers = new Map<string, ClientTableHandler<DB>>();
|
|
12
|
+
|
|
13
|
+
register(handler: ClientTableHandler<DB>): this {
|
|
14
|
+
if (this.handlers.has(handler.table)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Client table handler already registered: ${handler.table}`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
this.handlers.set(handler.table, handler);
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(table: string): ClientTableHandler<DB> | undefined {
|
|
24
|
+
return this.handlers.get(table);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getOrThrow(table: string): ClientTableHandler<DB> {
|
|
28
|
+
const h = this.handlers.get(table);
|
|
29
|
+
if (!h) throw new Error(`Missing client table handler for table: ${table}`);
|
|
30
|
+
return h;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getAll(): ClientTableHandler<DB>[] {
|
|
34
|
+
return Array.from(this.handlers.values());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/client - Sync client table handler interface
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ScopeValues, SyncChange, SyncSnapshot } from '@syncular/core';
|
|
6
|
+
import type { Transaction } from 'kysely';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Context passed to client table handler methods.
|
|
10
|
+
*/
|
|
11
|
+
export interface ClientHandlerContext<DB> {
|
|
12
|
+
/** Database transaction */
|
|
13
|
+
trx: Transaction<DB>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extended context for snapshot lifecycle hooks.
|
|
18
|
+
*/
|
|
19
|
+
export interface ClientSnapshotHookContext<DB>
|
|
20
|
+
extends ClientHandlerContext<DB> {
|
|
21
|
+
/** The table being snapshotted */
|
|
22
|
+
table: string;
|
|
23
|
+
/** The scope values for this subscription */
|
|
24
|
+
scopes: ScopeValues;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Context for clearAll/clearScope operations.
|
|
29
|
+
*/
|
|
30
|
+
export interface ClientClearContext<DB> extends ClientHandlerContext<DB> {
|
|
31
|
+
/** The scope values to clear (data matching these scopes should be removed) */
|
|
32
|
+
scopes: ScopeValues;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subscription configuration for a handler.
|
|
37
|
+
*/
|
|
38
|
+
export interface HandlerSubscriptionConfig {
|
|
39
|
+
/** Scope values for this subscription */
|
|
40
|
+
scopes?: Record<string, string | string[]>;
|
|
41
|
+
/** Params for this subscription */
|
|
42
|
+
params?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Client-side table handler for applying sync snapshots and changes.
|
|
47
|
+
*/
|
|
48
|
+
export interface ClientTableHandler<
|
|
49
|
+
DB,
|
|
50
|
+
TableName extends keyof DB & string = keyof DB & string,
|
|
51
|
+
> {
|
|
52
|
+
/** Table name (used as identifier in sync operations) */
|
|
53
|
+
table: TableName;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scope patterns used by this table.
|
|
57
|
+
* Used for deriving safe default subscriptions in `createClient`.
|
|
58
|
+
*/
|
|
59
|
+
scopePatterns?: string[];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Subscription configuration.
|
|
63
|
+
* - `true`: Subscribe to this table (default)
|
|
64
|
+
* - `false`: Don't subscribe (local-only handler)
|
|
65
|
+
* - Object: Subscribe with custom scopes/params
|
|
66
|
+
*/
|
|
67
|
+
subscribe?: boolean | HandlerSubscriptionConfig;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply a snapshot page for this table.
|
|
71
|
+
* The handler is responsible for upserting the rows.
|
|
72
|
+
*/
|
|
73
|
+
applySnapshot(
|
|
74
|
+
ctx: ClientHandlerContext<DB>,
|
|
75
|
+
snapshot: SyncSnapshot
|
|
76
|
+
): Promise<void>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Clear local data for this table matching the given scopes.
|
|
80
|
+
* Used when subscription is removed or revoked.
|
|
81
|
+
* If scopes is empty, clear all data for this table.
|
|
82
|
+
*/
|
|
83
|
+
clearAll(ctx: ClientClearContext<DB>): Promise<void>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Apply a single change (upsert/delete).
|
|
87
|
+
* Must be idempotent (retries may re-apply).
|
|
88
|
+
*/
|
|
89
|
+
applyChange(ctx: ClientHandlerContext<DB>, change: SyncChange): Promise<void>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Optional: Called when a snapshot begins (isFirstPage = true).
|
|
93
|
+
* Use this for marking existing rows as stale before applying snapshot.
|
|
94
|
+
*/
|
|
95
|
+
onSnapshotStart?(ctx: ClientSnapshotHookContext<DB>): Promise<void>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Optional: Called when a snapshot ends (isLastPage = true).
|
|
99
|
+
* Use this for cleaning up stale rows after snapshot is complete.
|
|
100
|
+
*/
|
|
101
|
+
onSnapshotEnd?(ctx: ClientSnapshotHookContext<DB>): Promise<void>;
|
|
102
|
+
}
|