@syncular/client 0.0.1-100

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 (178) 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 +339 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +881 -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 +112 -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 +216 -0
  30. package/dist/engine/SyncEngine.d.ts.map +1 -0
  31. package/dist/engine/SyncEngine.js +1141 -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 +142 -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 +601 -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 +294 -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 +381 -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 +59 -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.test.ts +369 -0
  147. package/src/client.ts +1288 -0
  148. package/src/conflicts.ts +171 -0
  149. package/src/create-client.ts +297 -0
  150. package/src/engine/SyncEngine.test.ts +157 -0
  151. package/src/engine/SyncEngine.ts +1464 -0
  152. package/src/engine/index.ts +6 -0
  153. package/src/engine/types.ts +268 -0
  154. package/src/handlers/create-handler.ts +298 -0
  155. package/src/handlers/registry.ts +36 -0
  156. package/src/handlers/types.ts +102 -0
  157. package/src/index.ts +25 -0
  158. package/src/migrate.ts +122 -0
  159. package/src/mutations.ts +912 -0
  160. package/src/outbox.ts +383 -0
  161. package/src/plugins/incrementing-version.ts +133 -0
  162. package/src/plugins/index.ts +2 -0
  163. package/src/plugins/types.ts +63 -0
  164. package/src/proxy/connection.ts +191 -0
  165. package/src/proxy/dialect.ts +76 -0
  166. package/src/proxy/driver.ts +126 -0
  167. package/src/proxy/index.ts +10 -0
  168. package/src/proxy/mutations.ts +18 -0
  169. package/src/pull-engine.ts +508 -0
  170. package/src/push-engine.ts +201 -0
  171. package/src/query/FingerprintCollector.ts +29 -0
  172. package/src/query/QueryContext.ts +54 -0
  173. package/src/query/fingerprint.ts +109 -0
  174. package/src/query/index.ts +10 -0
  175. package/src/query/tracked-select.ts +139 -0
  176. package/src/schema.ts +94 -0
  177. package/src/sync-loop.ts +368 -0
  178. package/src/utils/id.ts +20 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @syncular/client - Sync conflict storage helpers
3
+ */
4
+
5
+ import type { SyncOperationResult, SyncPushResponse } from '@syncular/core';
6
+ import { randomId } from '@syncular/core';
7
+ import type { Kysely } from 'kysely';
8
+ import { sql } from 'kysely';
9
+ import type { SyncClientDb } from './schema';
10
+
11
+ function messageFromResult(
12
+ r: Extract<SyncOperationResult, { status: 'conflict' | 'error' }>
13
+ ): {
14
+ message: string;
15
+ code: string | null;
16
+ serverVersion: number | null;
17
+ serverRowJson: string | null;
18
+ } {
19
+ if (r.status === 'conflict') {
20
+ return {
21
+ message: r.message,
22
+ code: 'CONFLICT',
23
+ serverVersion: r.server_version,
24
+ serverRowJson: JSON.stringify(r.server_row),
25
+ };
26
+ }
27
+
28
+ return {
29
+ message: r.error,
30
+ code: r.code ?? null,
31
+ serverVersion: null,
32
+ serverRowJson: null,
33
+ };
34
+ }
35
+
36
+ export async function upsertConflictsForRejectedCommit<DB extends SyncClientDb>(
37
+ db: Kysely<DB>,
38
+ args: {
39
+ outboxCommitId: string;
40
+ clientCommitId: string;
41
+ response: SyncPushResponse;
42
+ nowMs?: number;
43
+ }
44
+ ): Promise<number> {
45
+ const now = args.nowMs ?? Date.now();
46
+
47
+ // Remove any previous conflict rows for this outbox commit.
48
+ await sql`
49
+ delete from ${sql.table('sync_conflicts')}
50
+ where ${sql.ref('outbox_commit_id')} = ${sql.val(args.outboxCommitId)}
51
+ `.execute(db);
52
+
53
+ const conflictResults = args.response.results.filter(
54
+ (r) => r.status === 'conflict' || r.status === 'error'
55
+ );
56
+
57
+ if (conflictResults.length === 0) return 0;
58
+
59
+ const rows = conflictResults.map((r) => {
60
+ const info = messageFromResult(r);
61
+ return {
62
+ id: randomId(),
63
+ outbox_commit_id: args.outboxCommitId,
64
+ client_commit_id: args.clientCommitId,
65
+ op_index: r.opIndex,
66
+ result_status: r.status,
67
+ message: info.message,
68
+ code: info.code,
69
+ server_version: info.serverVersion,
70
+ server_row_json: info.serverRowJson,
71
+ created_at: now,
72
+ resolved_at: null,
73
+ resolution: null,
74
+ };
75
+ });
76
+
77
+ const insertColumns = [
78
+ 'id',
79
+ 'outbox_commit_id',
80
+ 'client_commit_id',
81
+ 'op_index',
82
+ 'result_status',
83
+ 'message',
84
+ 'code',
85
+ 'server_version',
86
+ 'server_row_json',
87
+ 'created_at',
88
+ 'resolved_at',
89
+ 'resolution',
90
+ ] as const;
91
+
92
+ await sql`
93
+ insert into ${sql.table('sync_conflicts')} (
94
+ ${sql.join(insertColumns.map((c) => sql.ref(c)))}
95
+ ) values ${sql.join(
96
+ rows.map(
97
+ (row) =>
98
+ sql`(${sql.join(
99
+ [
100
+ sql.val(row.id),
101
+ sql.val(row.outbox_commit_id),
102
+ sql.val(row.client_commit_id),
103
+ sql.val(row.op_index),
104
+ sql.val(row.result_status),
105
+ sql.val(row.message),
106
+ sql.val(row.code),
107
+ sql.val(row.server_version),
108
+ sql.val(row.server_row_json),
109
+ sql.val(row.created_at),
110
+ sql.val(row.resolved_at),
111
+ sql.val(row.resolution),
112
+ ],
113
+ sql`, `
114
+ )})`
115
+ ),
116
+ sql`, `
117
+ )}
118
+ `.execute(db);
119
+
120
+ return conflictResults.length;
121
+ }
122
+
123
+ export type PendingConflictRow = {
124
+ id: string;
125
+ outbox_commit_id: string;
126
+ client_commit_id: string;
127
+ op_index: number;
128
+ result_status: string;
129
+ message: string;
130
+ code: string | null;
131
+ server_version: number | null;
132
+ server_row_json: string | null;
133
+ created_at: number;
134
+ };
135
+
136
+ export async function listPendingConflicts<DB extends SyncClientDb>(
137
+ db: Kysely<DB>
138
+ ): Promise<PendingConflictRow[]> {
139
+ const res = await sql<PendingConflictRow>`
140
+ select
141
+ ${sql.ref('id')},
142
+ ${sql.ref('outbox_commit_id')},
143
+ ${sql.ref('client_commit_id')},
144
+ ${sql.ref('op_index')},
145
+ ${sql.ref('result_status')},
146
+ ${sql.ref('message')},
147
+ ${sql.ref('code')},
148
+ ${sql.ref('server_version')},
149
+ ${sql.ref('server_row_json')},
150
+ ${sql.ref('created_at')}
151
+ from ${sql.table('sync_conflicts')}
152
+ where ${sql.ref('resolved_at')} is null
153
+ order by ${sql.ref('created_at')} desc
154
+ `.execute(db);
155
+
156
+ return res.rows;
157
+ }
158
+
159
+ export async function resolveConflict<DB extends SyncClientDb>(
160
+ db: Kysely<DB>,
161
+ args: { id: string; resolution: string; nowMs?: number }
162
+ ): Promise<void> {
163
+ const now = args.nowMs ?? Date.now();
164
+ await sql`
165
+ update ${sql.table('sync_conflicts')}
166
+ set
167
+ ${sql.ref('resolved_at')} = ${sql.val(now)},
168
+ ${sql.ref('resolution')} = ${sql.val(args.resolution)}
169
+ where ${sql.ref('id')} = ${sql.val(args.id)}
170
+ `.execute(db);
171
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Simplified client factory
3
+ *
4
+ * Breaking changes from legacy Client:
5
+ * - handlers: array instead of ClientTableRegistry
6
+ * - url: string instead of transport (transport auto-created)
7
+ * - subscriptions: derived from handler.subscribe (no separate param)
8
+ * - clientId: auto-generated (no longer required)
9
+ */
10
+
11
+ import type {
12
+ ScopeDefinition,
13
+ ScopeValues,
14
+ SyncTransport,
15
+ } from '@syncular/core';
16
+ import { extractScopeVars } from '@syncular/core';
17
+ import { createHttpTransport } from '@syncular/transport-http';
18
+ import type { Kysely } from 'kysely';
19
+ import { Client } from './client';
20
+ import { createClientHandler } from './handlers/create-handler';
21
+ import { ClientTableRegistry } from './handlers/registry';
22
+ import type { ClientTableHandler } from './handlers/types';
23
+ import type { SyncClientDb } from './schema';
24
+ import { randomUUID } from './utils/id';
25
+
26
+ function deriveDefaultSubscriptionScopes<DB>(args: {
27
+ handler: Pick<ClientTableHandler<DB>, 'table' | 'scopePatterns'>;
28
+ actorId: string;
29
+ }): ScopeValues {
30
+ const patterns = args.handler.scopePatterns ?? [];
31
+ const vars = new Set<string>();
32
+ for (const pattern of patterns) {
33
+ for (const v of extractScopeVars(pattern)) {
34
+ vars.add(v);
35
+ }
36
+ }
37
+
38
+ const allVars = Array.from(vars);
39
+ if (allVars.length !== 1) {
40
+ throw new Error(
41
+ `Handler "${args.handler.table}" has subscribe=true but no explicit subscription scopes. ` +
42
+ 'Set subscribe: { scopes: { ... } } on the handler. ' +
43
+ `(Cannot infer defaults from scopePatterns: ${
44
+ patterns.length > 0 ? patterns.join(', ') : '(none)'
45
+ })`
46
+ );
47
+ }
48
+
49
+ const varName = allVars[0]!;
50
+ return { [varName]: args.actorId };
51
+ }
52
+
53
+ /**
54
+ * Auto-generate a simple handler for a table.
55
+ * Uses 'id' as primary key and provided scopes.
56
+ */
57
+ function createAutoHandler<
58
+ DB extends SyncClientDb,
59
+ TableName extends keyof DB & string,
60
+ >(table: string, scopes: string[]): ClientTableHandler<DB, TableName> {
61
+ return createClientHandler<DB, TableName>({
62
+ table: table as TableName,
63
+ scopes: scopes as ScopeDefinition[],
64
+ });
65
+ }
66
+
67
+ interface CreateClientOptions<DB extends SyncClientDb> {
68
+ /** Kysely database instance */
69
+ db: Kysely<DB>;
70
+
71
+ /**
72
+ * Server URL (e.g., '/api/sync' or 'https://api.example.com').
73
+ * Defaults to '/api/sync' if not provided.
74
+ * Ignored if transport is provided.
75
+ */
76
+ url?: string;
77
+
78
+ /**
79
+ * Table handlers for applying snapshots and changes.
80
+ * Array is auto-converted to ClientTableRegistry.
81
+ * Handlers with `subscribe: true` (or an object) are synced.
82
+ * Handlers with `subscribe: false` are local-only.
83
+ * Either handlers or tables must be provided.
84
+ */
85
+ handlers?: Array<ClientTableHandler<DB>>;
86
+
87
+ /**
88
+ * Table names to auto-generate handlers for.
89
+ * Uses default scopes and primary key 'id'.
90
+ * Either handlers or tables must be provided.
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * tables: ['tasks', 'notes', 'projects']
95
+ * ```
96
+ */
97
+ tables?: string[];
98
+
99
+ /**
100
+ * Default scopes for auto-generated table handlers.
101
+ * Required when using tables option.
102
+ * Ignored when handlers are provided.
103
+ */
104
+ scopes?: string[];
105
+
106
+ /** Current actor/user identifier */
107
+ actorId: string;
108
+
109
+ /** Optional: Custom client ID (auto-generated UUID if not provided) */
110
+ clientId?: string;
111
+
112
+ /** Optional: Custom transport (overrides url) */
113
+ transport?: SyncTransport;
114
+
115
+ /** Optional: Function to get auth headers */
116
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
117
+
118
+ /** Optional: Sync configuration */
119
+ sync?: {
120
+ /** Enable realtime/WebSocket mode (default: true) */
121
+ realtime?: boolean;
122
+ /** Polling interval in ms (default: 10000) */
123
+ pollIntervalMs?: number;
124
+ };
125
+
126
+ /** Optional: Local blob storage adapter */
127
+ blobStorage?: import('./client').ClientBlobStorage;
128
+
129
+ /** Optional: Sync plugins */
130
+ plugins?: import('./plugins').SyncClientPlugin[];
131
+
132
+ /** Optional: State ID for multi-tenant scenarios */
133
+ stateId?: string;
134
+
135
+ /** Optional: Auto-start sync (default: true) */
136
+ autoStart?: boolean;
137
+ }
138
+
139
+ interface CreateClientResult<DB extends SyncClientDb> {
140
+ /** The client instance */
141
+ client: Client<DB>;
142
+ /** Stop sync */
143
+ stop: () => void;
144
+ /** Destroy client and cleanup */
145
+ destroy: () => void;
146
+ }
147
+
148
+ /**
149
+ * Create a simplified sync client.
150
+ *
151
+ * Auto-generates clientId, creates transport from URL (default: '/api/sync'),
152
+ * builds subscriptions from handlers.
153
+ *
154
+ * @example
155
+ * ```typescript
156
+ * // Auto-generate handlers for simple tables (default URL: '/api/sync')
157
+ * const { client } = await createClient({
158
+ * db,
159
+ * actorId: 'user-123',
160
+ * tables: ['tasks', 'notes'],
161
+ * scopes: ['user:{user_id}'],
162
+ * });
163
+ *
164
+ * // With custom handlers for advanced cases
165
+ * const { client } = await createClient({
166
+ * db,
167
+ * actorId: 'user-123',
168
+ * handlers: [
169
+ * tasksHandler, // subscribe: true by default
170
+ * notesHandler, // subscribe: true by default
171
+ * draftsHandler, // subscribe: false (local-only)
172
+ * ],
173
+ * });
174
+ *
175
+ * // Listen for events
176
+ * client.on('sync:error', (err) => console.error(err));
177
+ * client.on('data:change', (scopes) => console.log('Data changed:', scopes));
178
+ * ```
179
+ */
180
+ export async function createClient<DB extends SyncClientDb>(
181
+ options: CreateClientOptions<DB>
182
+ ): Promise<CreateClientResult<DB>> {
183
+ const {
184
+ db,
185
+ url = '/api/sync',
186
+ handlers: providedHandlers,
187
+ tables,
188
+ scopes,
189
+ actorId,
190
+ clientId = randomUUID(),
191
+ transport: customTransport,
192
+ getHeaders,
193
+ sync = {},
194
+ blobStorage,
195
+ plugins,
196
+ stateId,
197
+ autoStart = true,
198
+ } = options;
199
+
200
+ // Validate options
201
+ if (!providedHandlers && !tables) {
202
+ throw new Error('Either handlers or tables must be provided');
203
+ }
204
+ if (tables && !scopes) {
205
+ throw new Error('scopes is required when using tables option');
206
+ }
207
+
208
+ // Auto-generate handlers from tables if needed
209
+ const handlers =
210
+ providedHandlers ??
211
+ tables!.map((table) =>
212
+ createAutoHandler<DB, keyof DB & string>(table, scopes!)
213
+ );
214
+
215
+ // Build registry from handlers array
216
+ const tableHandlers = new ClientTableRegistry<DB>();
217
+ for (const handler of handlers) {
218
+ tableHandlers.register(handler);
219
+ }
220
+
221
+ // Create transport from URL if not provided
222
+ let transport = customTransport;
223
+ if (!transport && url) {
224
+ transport = createHttpTransport({
225
+ baseUrl: url,
226
+ getHeaders,
227
+ });
228
+ }
229
+
230
+ if (!transport) {
231
+ throw new Error('Either url or transport must be provided');
232
+ }
233
+
234
+ // Build subscriptions from handlers
235
+ const subscriptions = handlers
236
+ .map((handler) => {
237
+ const sub = handler.subscribe;
238
+ // Skip handlers that are explicitly disabled
239
+ if (sub === false) return null;
240
+
241
+ if (sub === true || sub === undefined) {
242
+ // Default: subscribe to the handler's single scope var using actorId.
243
+ // This avoids sending `{}` which would be treated as revoked by the server.
244
+ const scopes = deriveDefaultSubscriptionScopes({
245
+ handler,
246
+ actorId,
247
+ });
248
+ return {
249
+ id: handler.table,
250
+ shape: handler.table,
251
+ scopes,
252
+ params: {},
253
+ };
254
+ }
255
+ // Custom subscription config
256
+ const scopes: ScopeValues = sub.scopes ?? {};
257
+ if (Object.keys(scopes).length === 0) {
258
+ throw new Error(
259
+ `Handler "${handler.table}" subscription scopes cannot be empty. ` +
260
+ 'Set subscribe: false or provide subscribe.scopes.'
261
+ );
262
+ }
263
+ return {
264
+ id: handler.table,
265
+ shape: handler.table,
266
+ scopes,
267
+ params: sub.params ?? {},
268
+ };
269
+ })
270
+ .filter((sub): sub is NonNullable<typeof sub> => sub !== null);
271
+
272
+ // Create client
273
+ const client = new Client({
274
+ db,
275
+ transport,
276
+ tableHandlers,
277
+ clientId,
278
+ actorId,
279
+ subscriptions,
280
+ blobStorage,
281
+ plugins,
282
+ stateId,
283
+ realtimeEnabled: sync.realtime ?? true,
284
+ pollIntervalMs: sync.pollIntervalMs,
285
+ });
286
+
287
+ // Auto-start
288
+ if (autoStart) {
289
+ await client.start();
290
+ }
291
+
292
+ return {
293
+ client,
294
+ stop: () => client.stop(),
295
+ destroy: () => client.destroy(),
296
+ };
297
+ }
@@ -0,0 +1,157 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import type { SyncChange, SyncTransport } from '@syncular/core';
3
+ import type { Kysely } from 'kysely';
4
+ import { sql } from 'kysely';
5
+ import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
6
+ import { ClientTableRegistry } from '../handlers/registry';
7
+ import { ensureClientSyncSchema } from '../migrate';
8
+ import type { SyncClientDb } from '../schema';
9
+ import { SyncEngine } from './SyncEngine';
10
+
11
+ interface TasksTable {
12
+ id: string;
13
+ title: string;
14
+ server_version: number;
15
+ }
16
+
17
+ interface TestDb extends SyncClientDb {
18
+ tasks: TasksTable;
19
+ }
20
+
21
+ const noopTransport: SyncTransport = {
22
+ async sync() {
23
+ return {};
24
+ },
25
+ async fetchSnapshotChunk() {
26
+ return new Uint8Array();
27
+ },
28
+ };
29
+
30
+ describe('SyncEngine WS inline apply', () => {
31
+ let db: Kysely<TestDb>;
32
+
33
+ beforeEach(async () => {
34
+ db = createBunSqliteDb<TestDb>({ path: ':memory:' });
35
+ await ensureClientSyncSchema(db);
36
+
37
+ await db.schema
38
+ .createTable('tasks')
39
+ .addColumn('id', 'text', (col) => col.primaryKey())
40
+ .addColumn('title', 'text', (col) => col.notNull())
41
+ .addColumn('server_version', 'integer', (col) =>
42
+ col.notNull().defaultTo(0)
43
+ )
44
+ .execute();
45
+
46
+ await db
47
+ .insertInto('tasks')
48
+ .values({
49
+ id: 't1',
50
+ title: 'old',
51
+ server_version: 1,
52
+ })
53
+ .execute();
54
+
55
+ await db
56
+ .insertInto('sync_subscription_state')
57
+ .values({
58
+ state_id: 'default',
59
+ subscription_id: 'sub-1',
60
+ shape: 'tasks',
61
+ scopes_json: '{}',
62
+ params_json: '{}',
63
+ cursor: 0,
64
+ bootstrap_state_json: null,
65
+ status: 'active',
66
+ created_at: Date.now(),
67
+ updated_at: Date.now(),
68
+ })
69
+ .execute();
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await db.destroy();
74
+ });
75
+
76
+ it('rolls back row updates and cursor when any inline WS change fails', async () => {
77
+ const shapes = new ClientTableRegistry<TestDb>().register({
78
+ table: 'tasks',
79
+ async applySnapshot() {},
80
+ async clearAll() {},
81
+ async applyChange(ctx, change) {
82
+ if (change.row_id === 'fail') {
83
+ throw new Error('forced apply failure');
84
+ }
85
+ const rowJson =
86
+ change.row_json && typeof change.row_json === 'object'
87
+ ? change.row_json
88
+ : null;
89
+ const title =
90
+ rowJson && 'title' in rowJson ? String(rowJson.title ?? '') : '';
91
+ await sql`
92
+ update ${sql.table('tasks')}
93
+ set
94
+ ${sql.ref('title')} = ${sql.val(title)},
95
+ ${sql.ref('server_version')} = ${sql.val(Number(change.row_version ?? 0))}
96
+ where ${sql.ref('id')} = ${sql.val(change.row_id)}
97
+ `.execute(ctx.trx);
98
+ },
99
+ });
100
+
101
+ const engine = new SyncEngine<TestDb>({
102
+ db,
103
+ transport: noopTransport,
104
+ shapes,
105
+ actorId: 'u1',
106
+ clientId: 'client-1',
107
+ subscriptions: [],
108
+ stateId: 'default',
109
+ });
110
+
111
+ const changes: SyncChange[] = [
112
+ {
113
+ table: 'tasks',
114
+ row_id: 't1',
115
+ op: 'upsert',
116
+ row_json: { id: 't1', title: 'new' },
117
+ row_version: 2,
118
+ scopes: {},
119
+ },
120
+ {
121
+ table: 'tasks',
122
+ row_id: 'fail',
123
+ op: 'upsert',
124
+ row_json: { id: 'fail', title: 'bad' },
125
+ row_version: 1,
126
+ scopes: {},
127
+ },
128
+ ];
129
+
130
+ const applyWsDeliveredChanges = Reflect.get(
131
+ engine,
132
+ 'applyWsDeliveredChanges'
133
+ );
134
+ if (typeof applyWsDeliveredChanges !== 'function') {
135
+ throw new Error('Expected applyWsDeliveredChanges to be callable');
136
+ }
137
+ const applied = await applyWsDeliveredChanges.call(engine, changes, 10);
138
+
139
+ expect(applied).toBe(false);
140
+
141
+ const task = await db
142
+ .selectFrom('tasks')
143
+ .select(['title', 'server_version'])
144
+ .where('id', '=', 't1')
145
+ .executeTakeFirstOrThrow();
146
+ expect(task.title).toBe('old');
147
+ expect(task.server_version).toBe(1);
148
+
149
+ const state = await db
150
+ .selectFrom('sync_subscription_state')
151
+ .select(['cursor'])
152
+ .where('state_id', '=', 'default')
153
+ .where('subscription_id', '=', 'sub-1')
154
+ .executeTakeFirstOrThrow();
155
+ expect(state.cursor).toBe(0);
156
+ });
157
+ });