@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.
Files changed (176) hide show
  1. package/dist/blobs/index.d.ts +7 -0
  2. package/dist/blobs/index.d.ts.map +1 -0
  3. package/dist/blobs/index.js +7 -0
  4. package/dist/blobs/index.js.map +1 -0
  5. package/dist/blobs/manager.d.ts +345 -0
  6. package/dist/blobs/manager.d.ts.map +1 -0
  7. package/dist/blobs/manager.js +749 -0
  8. package/dist/blobs/manager.js.map +1 -0
  9. package/dist/blobs/migrate.d.ts +14 -0
  10. package/dist/blobs/migrate.d.ts.map +1 -0
  11. package/dist/blobs/migrate.js +59 -0
  12. package/dist/blobs/migrate.js.map +1 -0
  13. package/dist/blobs/types.d.ts +62 -0
  14. package/dist/blobs/types.d.ts.map +1 -0
  15. package/dist/blobs/types.js +5 -0
  16. package/dist/blobs/types.js.map +1 -0
  17. package/dist/client.d.ts +338 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +834 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/conflicts.d.ts +31 -0
  22. package/dist/conflicts.d.ts.map +1 -0
  23. package/dist/conflicts.js +118 -0
  24. package/dist/conflicts.js.map +1 -0
  25. package/dist/create-client.d.ts +115 -0
  26. package/dist/create-client.d.ts.map +1 -0
  27. package/dist/create-client.js +162 -0
  28. package/dist/create-client.js.map +1 -0
  29. package/dist/engine/SyncEngine.d.ts +215 -0
  30. package/dist/engine/SyncEngine.d.ts.map +1 -0
  31. package/dist/engine/SyncEngine.js +1066 -0
  32. package/dist/engine/SyncEngine.js.map +1 -0
  33. package/dist/engine/index.d.ts +6 -0
  34. package/dist/engine/index.d.ts.map +1 -0
  35. package/dist/engine/index.js +6 -0
  36. package/dist/engine/index.js.map +1 -0
  37. package/dist/engine/types.d.ts +230 -0
  38. package/dist/engine/types.d.ts.map +1 -0
  39. package/dist/engine/types.js +7 -0
  40. package/dist/engine/types.js.map +1 -0
  41. package/dist/handlers/create-handler.d.ts +110 -0
  42. package/dist/handlers/create-handler.d.ts.map +1 -0
  43. package/dist/handlers/create-handler.js +140 -0
  44. package/dist/handlers/create-handler.js.map +1 -0
  45. package/dist/handlers/registry.d.ts +15 -0
  46. package/dist/handlers/registry.d.ts.map +1 -0
  47. package/dist/handlers/registry.js +29 -0
  48. package/dist/handlers/registry.js.map +1 -0
  49. package/dist/handlers/types.d.ts +83 -0
  50. package/dist/handlers/types.d.ts.map +1 -0
  51. package/dist/handlers/types.js +5 -0
  52. package/dist/handlers/types.js.map +1 -0
  53. package/dist/index.d.ts +24 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +24 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/migrate.d.ts +19 -0
  58. package/dist/migrate.d.ts.map +1 -0
  59. package/dist/migrate.js +106 -0
  60. package/dist/migrate.js.map +1 -0
  61. package/dist/mutations.d.ts +138 -0
  62. package/dist/mutations.d.ts.map +1 -0
  63. package/dist/mutations.js +611 -0
  64. package/dist/mutations.js.map +1 -0
  65. package/dist/outbox.d.ts +112 -0
  66. package/dist/outbox.d.ts.map +1 -0
  67. package/dist/outbox.js +304 -0
  68. package/dist/outbox.js.map +1 -0
  69. package/dist/plugins/incrementing-version.d.ts +34 -0
  70. package/dist/plugins/incrementing-version.d.ts.map +1 -0
  71. package/dist/plugins/incrementing-version.js +83 -0
  72. package/dist/plugins/incrementing-version.js.map +1 -0
  73. package/dist/plugins/index.d.ts +3 -0
  74. package/dist/plugins/index.d.ts.map +1 -0
  75. package/dist/plugins/index.js +3 -0
  76. package/dist/plugins/index.js.map +1 -0
  77. package/dist/plugins/types.d.ts +49 -0
  78. package/dist/plugins/types.d.ts.map +1 -0
  79. package/dist/plugins/types.js +15 -0
  80. package/dist/plugins/types.js.map +1 -0
  81. package/dist/proxy/connection.d.ts +33 -0
  82. package/dist/proxy/connection.d.ts.map +1 -0
  83. package/dist/proxy/connection.js +153 -0
  84. package/dist/proxy/connection.js.map +1 -0
  85. package/dist/proxy/dialect.d.ts +46 -0
  86. package/dist/proxy/dialect.d.ts.map +1 -0
  87. package/dist/proxy/dialect.js +58 -0
  88. package/dist/proxy/dialect.js.map +1 -0
  89. package/dist/proxy/driver.d.ts +42 -0
  90. package/dist/proxy/driver.d.ts.map +1 -0
  91. package/dist/proxy/driver.js +78 -0
  92. package/dist/proxy/driver.js.map +1 -0
  93. package/dist/proxy/index.d.ts +10 -0
  94. package/dist/proxy/index.d.ts.map +1 -0
  95. package/dist/proxy/index.js +10 -0
  96. package/dist/proxy/index.js.map +1 -0
  97. package/dist/proxy/mutations.d.ts +9 -0
  98. package/dist/proxy/mutations.d.ts.map +1 -0
  99. package/dist/proxy/mutations.js +11 -0
  100. package/dist/proxy/mutations.js.map +1 -0
  101. package/dist/pull-engine.d.ts +45 -0
  102. package/dist/pull-engine.d.ts.map +1 -0
  103. package/dist/pull-engine.js +391 -0
  104. package/dist/pull-engine.js.map +1 -0
  105. package/dist/push-engine.d.ts +18 -0
  106. package/dist/push-engine.d.ts.map +1 -0
  107. package/dist/push-engine.js +155 -0
  108. package/dist/push-engine.js.map +1 -0
  109. package/dist/query/FingerprintCollector.d.ts +18 -0
  110. package/dist/query/FingerprintCollector.d.ts.map +1 -0
  111. package/dist/query/FingerprintCollector.js +28 -0
  112. package/dist/query/FingerprintCollector.js.map +1 -0
  113. package/dist/query/QueryContext.d.ts +33 -0
  114. package/dist/query/QueryContext.d.ts.map +1 -0
  115. package/dist/query/QueryContext.js +16 -0
  116. package/dist/query/QueryContext.js.map +1 -0
  117. package/dist/query/fingerprint.d.ts +61 -0
  118. package/dist/query/fingerprint.d.ts.map +1 -0
  119. package/dist/query/fingerprint.js +91 -0
  120. package/dist/query/fingerprint.js.map +1 -0
  121. package/dist/query/index.d.ts +7 -0
  122. package/dist/query/index.d.ts.map +1 -0
  123. package/dist/query/index.js +7 -0
  124. package/dist/query/index.js.map +1 -0
  125. package/dist/query/tracked-select.d.ts +18 -0
  126. package/dist/query/tracked-select.d.ts.map +1 -0
  127. package/dist/query/tracked-select.js +90 -0
  128. package/dist/query/tracked-select.js.map +1 -0
  129. package/dist/schema.d.ts +83 -0
  130. package/dist/schema.d.ts.map +1 -0
  131. package/dist/schema.js +7 -0
  132. package/dist/schema.js.map +1 -0
  133. package/dist/sync-loop.d.ts +32 -0
  134. package/dist/sync-loop.d.ts.map +1 -0
  135. package/dist/sync-loop.js +249 -0
  136. package/dist/sync-loop.js.map +1 -0
  137. package/dist/utils/id.d.ts +8 -0
  138. package/dist/utils/id.d.ts.map +1 -0
  139. package/dist/utils/id.js +19 -0
  140. package/dist/utils/id.js.map +1 -0
  141. package/package.json +58 -0
  142. package/src/blobs/index.ts +7 -0
  143. package/src/blobs/manager.ts +1027 -0
  144. package/src/blobs/migrate.ts +67 -0
  145. package/src/blobs/types.ts +84 -0
  146. package/src/client.ts +1222 -0
  147. package/src/conflicts.ts +180 -0
  148. package/src/create-client.ts +297 -0
  149. package/src/engine/SyncEngine.ts +1337 -0
  150. package/src/engine/index.ts +6 -0
  151. package/src/engine/types.ts +268 -0
  152. package/src/handlers/create-handler.ts +287 -0
  153. package/src/handlers/registry.ts +36 -0
  154. package/src/handlers/types.ts +102 -0
  155. package/src/index.ts +25 -0
  156. package/src/migrate.ts +122 -0
  157. package/src/mutations.ts +926 -0
  158. package/src/outbox.ts +397 -0
  159. package/src/plugins/incrementing-version.ts +133 -0
  160. package/src/plugins/index.ts +2 -0
  161. package/src/plugins/types.ts +63 -0
  162. package/src/proxy/connection.ts +191 -0
  163. package/src/proxy/dialect.ts +76 -0
  164. package/src/proxy/driver.ts +126 -0
  165. package/src/proxy/index.ts +10 -0
  166. package/src/proxy/mutations.ts +18 -0
  167. package/src/pull-engine.ts +518 -0
  168. package/src/push-engine.ts +201 -0
  169. package/src/query/FingerprintCollector.ts +29 -0
  170. package/src/query/QueryContext.ts +54 -0
  171. package/src/query/fingerprint.ts +109 -0
  172. package/src/query/index.ts +10 -0
  173. package/src/query/tracked-select.ts +139 -0
  174. package/src/schema.ts +94 -0
  175. package/src/sync-loop.ts +368 -0
  176. package/src/utils/id.ts +20 -0
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @syncular/client - Engine exports
3
+ */
4
+
5
+ export { SyncEngine } from './SyncEngine';
6
+ export * from './types';
@@ -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
+ }