@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,180 @@
1
+ /**
2
+ * @syncular/client - Sync conflict storage helpers
3
+ */
4
+
5
+ import type { SyncOperationResult, SyncPushResponse } from '@syncular/core';
6
+ import type { Kysely } from 'kysely';
7
+ import { sql } from 'kysely';
8
+ import type { SyncClientDb } from './schema';
9
+
10
+ function randomId(): string {
11
+ if (
12
+ typeof crypto !== 'undefined' &&
13
+ typeof crypto.randomUUID === 'function'
14
+ ) {
15
+ return crypto.randomUUID();
16
+ }
17
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
18
+ }
19
+
20
+ function messageFromResult(
21
+ r: Extract<SyncOperationResult, { status: 'conflict' | 'error' }>
22
+ ): {
23
+ message: string;
24
+ code: string | null;
25
+ serverVersion: number | null;
26
+ serverRowJson: string | null;
27
+ } {
28
+ if (r.status === 'conflict') {
29
+ return {
30
+ message: r.message,
31
+ code: 'CONFLICT',
32
+ serverVersion: r.server_version,
33
+ serverRowJson: JSON.stringify(r.server_row),
34
+ };
35
+ }
36
+
37
+ return {
38
+ message: r.error,
39
+ code: r.code ?? null,
40
+ serverVersion: null,
41
+ serverRowJson: null,
42
+ };
43
+ }
44
+
45
+ export async function upsertConflictsForRejectedCommit<DB extends SyncClientDb>(
46
+ db: Kysely<DB>,
47
+ args: {
48
+ outboxCommitId: string;
49
+ clientCommitId: string;
50
+ response: SyncPushResponse;
51
+ nowMs?: number;
52
+ }
53
+ ): Promise<number> {
54
+ const now = args.nowMs ?? Date.now();
55
+
56
+ // Remove any previous conflict rows for this outbox commit.
57
+ await sql`
58
+ delete from ${sql.table('sync_conflicts')}
59
+ where ${sql.ref('outbox_commit_id')} = ${sql.val(args.outboxCommitId)}
60
+ `.execute(db);
61
+
62
+ const conflictResults = args.response.results.filter(
63
+ (r) => r.status === 'conflict' || r.status === 'error'
64
+ );
65
+
66
+ if (conflictResults.length === 0) return 0;
67
+
68
+ const rows = conflictResults.map((r) => {
69
+ const info = messageFromResult(r);
70
+ return {
71
+ id: randomId(),
72
+ outbox_commit_id: args.outboxCommitId,
73
+ client_commit_id: args.clientCommitId,
74
+ op_index: r.opIndex,
75
+ result_status: r.status,
76
+ message: info.message,
77
+ code: info.code,
78
+ server_version: info.serverVersion,
79
+ server_row_json: info.serverRowJson,
80
+ created_at: now,
81
+ resolved_at: null,
82
+ resolution: null,
83
+ };
84
+ });
85
+
86
+ const insertColumns = [
87
+ 'id',
88
+ 'outbox_commit_id',
89
+ 'client_commit_id',
90
+ 'op_index',
91
+ 'result_status',
92
+ 'message',
93
+ 'code',
94
+ 'server_version',
95
+ 'server_row_json',
96
+ 'created_at',
97
+ 'resolved_at',
98
+ 'resolution',
99
+ ] as const;
100
+
101
+ await sql`
102
+ insert into ${sql.table('sync_conflicts')} (
103
+ ${sql.join(insertColumns.map((c) => sql.ref(c)))}
104
+ ) values ${sql.join(
105
+ rows.map(
106
+ (row) =>
107
+ sql`(${sql.join(
108
+ [
109
+ sql.val(row.id),
110
+ sql.val(row.outbox_commit_id),
111
+ sql.val(row.client_commit_id),
112
+ sql.val(row.op_index),
113
+ sql.val(row.result_status),
114
+ sql.val(row.message),
115
+ sql.val(row.code),
116
+ sql.val(row.server_version),
117
+ sql.val(row.server_row_json),
118
+ sql.val(row.created_at),
119
+ sql.val(row.resolved_at),
120
+ sql.val(row.resolution),
121
+ ],
122
+ sql`, `
123
+ )})`
124
+ ),
125
+ sql`, `
126
+ )}
127
+ `.execute(db);
128
+
129
+ return conflictResults.length;
130
+ }
131
+
132
+ export type PendingConflictRow = {
133
+ id: string;
134
+ outbox_commit_id: string;
135
+ client_commit_id: string;
136
+ op_index: number;
137
+ result_status: string;
138
+ message: string;
139
+ code: string | null;
140
+ server_version: number | null;
141
+ server_row_json: string | null;
142
+ created_at: number;
143
+ };
144
+
145
+ export async function listPendingConflicts<DB extends SyncClientDb>(
146
+ db: Kysely<DB>
147
+ ): Promise<PendingConflictRow[]> {
148
+ const res = await sql<PendingConflictRow>`
149
+ select
150
+ ${sql.ref('id')},
151
+ ${sql.ref('outbox_commit_id')},
152
+ ${sql.ref('client_commit_id')},
153
+ ${sql.ref('op_index')},
154
+ ${sql.ref('result_status')},
155
+ ${sql.ref('message')},
156
+ ${sql.ref('code')},
157
+ ${sql.ref('server_version')},
158
+ ${sql.ref('server_row_json')},
159
+ ${sql.ref('created_at')}
160
+ from ${sql.table('sync_conflicts')}
161
+ where ${sql.ref('resolved_at')} is null
162
+ order by ${sql.ref('created_at')} desc
163
+ `.execute(db);
164
+
165
+ return res.rows;
166
+ }
167
+
168
+ export async function resolveConflict<DB extends SyncClientDb>(
169
+ db: Kysely<DB>,
170
+ args: { id: string; resolution: string; nowMs?: number }
171
+ ): Promise<void> {
172
+ const now = args.nowMs ?? Date.now();
173
+ await sql`
174
+ update ${sql.table('sync_conflicts')}
175
+ set
176
+ ${sql.ref('resolved_at')} = ${sql.val(now)},
177
+ ${sql.ref('resolution')} = ${sql.val(args.resolution)}
178
+ where ${sql.ref('id')} = ${sql.val(args.id)}
179
+ `.execute(db);
180
+ }
@@ -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
+ }