@syncular/server 0.0.1 → 0.0.2-126

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 (171) hide show
  1. package/README.md +25 -0
  2. package/dist/blobs/adapters/database.d.ts.map +1 -1
  3. package/dist/blobs/adapters/database.js +25 -3
  4. package/dist/blobs/adapters/database.js.map +1 -1
  5. package/dist/blobs/adapters/filesystem.d.ts +31 -0
  6. package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
  7. package/dist/blobs/adapters/filesystem.js +140 -0
  8. package/dist/blobs/adapters/filesystem.js.map +1 -0
  9. package/dist/blobs/adapters/s3.d.ts +3 -2
  10. package/dist/blobs/adapters/s3.d.ts.map +1 -1
  11. package/dist/blobs/adapters/s3.js +49 -0
  12. package/dist/blobs/adapters/s3.js.map +1 -1
  13. package/dist/blobs/index.d.ts +1 -0
  14. package/dist/blobs/index.d.ts.map +1 -1
  15. package/dist/blobs/index.js +6 -5
  16. package/dist/blobs/index.js.map +1 -1
  17. package/dist/clients.d.ts +1 -0
  18. package/dist/clients.d.ts.map +1 -1
  19. package/dist/clients.js.map +1 -1
  20. package/dist/compaction.d.ts +1 -1
  21. package/dist/compaction.js +1 -1
  22. package/dist/dialect/base.d.ts +83 -0
  23. package/dist/dialect/base.d.ts.map +1 -0
  24. package/dist/dialect/base.js +144 -0
  25. package/dist/dialect/base.js.map +1 -0
  26. package/dist/dialect/helpers.d.ts +10 -0
  27. package/dist/dialect/helpers.d.ts.map +1 -0
  28. package/dist/dialect/helpers.js +59 -0
  29. package/dist/dialect/helpers.js.map +1 -0
  30. package/dist/dialect/index.d.ts +2 -0
  31. package/dist/dialect/index.d.ts.map +1 -1
  32. package/dist/dialect/index.js +3 -1
  33. package/dist/dialect/index.js.map +1 -1
  34. package/dist/dialect/types.d.ts +38 -46
  35. package/dist/dialect/types.d.ts.map +1 -1
  36. package/dist/{shapes → handlers}/create-handler.d.ts +18 -5
  37. package/dist/handlers/create-handler.d.ts.map +1 -0
  38. package/dist/{shapes → handlers}/create-handler.js +140 -43
  39. package/dist/handlers/create-handler.js.map +1 -0
  40. package/dist/handlers/index.d.ts.map +1 -0
  41. package/dist/handlers/index.js +4 -0
  42. package/dist/handlers/index.js.map +1 -0
  43. package/dist/handlers/registry.d.ts.map +1 -0
  44. package/dist/handlers/registry.js.map +1 -0
  45. package/dist/{shapes → handlers}/types.d.ts +7 -7
  46. package/dist/{shapes → handlers}/types.d.ts.map +1 -1
  47. package/dist/{shapes → handlers}/types.js.map +1 -1
  48. package/dist/helpers/conflict.d.ts +1 -1
  49. package/dist/helpers/conflict.d.ts.map +1 -1
  50. package/dist/helpers/emitted-change.d.ts +1 -1
  51. package/dist/helpers/emitted-change.d.ts.map +1 -1
  52. package/dist/helpers/index.js +4 -4
  53. package/dist/index.d.ts +2 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +17 -16
  56. package/dist/index.js.map +1 -1
  57. package/dist/notify.d.ts +47 -0
  58. package/dist/notify.d.ts.map +1 -0
  59. package/dist/notify.js +85 -0
  60. package/dist/notify.js.map +1 -0
  61. package/dist/proxy/handler.d.ts +1 -1
  62. package/dist/proxy/handler.d.ts.map +1 -1
  63. package/dist/proxy/handler.js +15 -11
  64. package/dist/proxy/handler.js.map +1 -1
  65. package/dist/proxy/index.d.ts +2 -2
  66. package/dist/proxy/index.d.ts.map +1 -1
  67. package/dist/proxy/index.js +3 -3
  68. package/dist/proxy/index.js.map +1 -1
  69. package/dist/proxy/mutation-detector.d.ts +4 -0
  70. package/dist/proxy/mutation-detector.d.ts.map +1 -1
  71. package/dist/proxy/mutation-detector.js +209 -24
  72. package/dist/proxy/mutation-detector.js.map +1 -1
  73. package/dist/proxy/oplog.d.ts +2 -1
  74. package/dist/proxy/oplog.d.ts.map +1 -1
  75. package/dist/proxy/oplog.js +15 -9
  76. package/dist/proxy/oplog.js.map +1 -1
  77. package/dist/proxy/registry.d.ts +0 -11
  78. package/dist/proxy/registry.d.ts.map +1 -1
  79. package/dist/proxy/registry.js +0 -24
  80. package/dist/proxy/registry.js.map +1 -1
  81. package/dist/proxy/types.d.ts +2 -0
  82. package/dist/proxy/types.d.ts.map +1 -1
  83. package/dist/pull.d.ts +4 -3
  84. package/dist/pull.d.ts.map +1 -1
  85. package/dist/pull.js +565 -314
  86. package/dist/pull.js.map +1 -1
  87. package/dist/push.d.ts +15 -3
  88. package/dist/push.d.ts.map +1 -1
  89. package/dist/push.js +359 -229
  90. package/dist/push.js.map +1 -1
  91. package/dist/realtime/index.js +1 -1
  92. package/dist/realtime/types.d.ts +2 -0
  93. package/dist/realtime/types.d.ts.map +1 -1
  94. package/dist/schema.d.ts +11 -1
  95. package/dist/schema.d.ts.map +1 -1
  96. package/dist/snapshot-chunks/db-metadata.d.ts +6 -1
  97. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
  98. package/dist/snapshot-chunks/db-metadata.js +261 -92
  99. package/dist/snapshot-chunks/db-metadata.js.map +1 -1
  100. package/dist/snapshot-chunks/index.d.ts +0 -1
  101. package/dist/snapshot-chunks/index.d.ts.map +1 -1
  102. package/dist/snapshot-chunks/index.js +2 -3
  103. package/dist/snapshot-chunks/index.js.map +1 -1
  104. package/dist/snapshot-chunks/types.d.ts +20 -5
  105. package/dist/snapshot-chunks/types.d.ts.map +1 -1
  106. package/dist/snapshot-chunks.d.ts +12 -8
  107. package/dist/snapshot-chunks.d.ts.map +1 -1
  108. package/dist/snapshot-chunks.js +40 -12
  109. package/dist/snapshot-chunks.js.map +1 -1
  110. package/dist/subscriptions/index.js +1 -1
  111. package/dist/subscriptions/resolve.d.ts +6 -6
  112. package/dist/subscriptions/resolve.d.ts.map +1 -1
  113. package/dist/subscriptions/resolve.js +53 -14
  114. package/dist/subscriptions/resolve.js.map +1 -1
  115. package/package.json +28 -7
  116. package/src/blobs/adapters/database.test.ts +67 -0
  117. package/src/blobs/adapters/database.ts +34 -9
  118. package/src/blobs/adapters/filesystem.test.ts +132 -0
  119. package/src/blobs/adapters/filesystem.ts +189 -0
  120. package/src/blobs/adapters/s3.test.ts +522 -0
  121. package/src/blobs/adapters/s3.ts +55 -2
  122. package/src/blobs/index.ts +1 -0
  123. package/src/clients.ts +1 -0
  124. package/src/compaction.ts +1 -1
  125. package/src/dialect/base.ts +292 -0
  126. package/src/dialect/helpers.ts +61 -0
  127. package/src/dialect/index.ts +2 -0
  128. package/src/dialect/types.ts +50 -54
  129. package/src/{shapes → handlers}/create-handler.ts +219 -64
  130. package/src/{shapes → handlers}/types.ts +10 -7
  131. package/src/helpers/conflict.ts +1 -1
  132. package/src/helpers/emitted-change.ts +1 -1
  133. package/src/index.ts +2 -1
  134. package/src/notify.test.ts +516 -0
  135. package/src/notify.ts +131 -0
  136. package/src/proxy/handler.test.ts +120 -0
  137. package/src/proxy/handler.ts +18 -10
  138. package/src/proxy/index.ts +2 -1
  139. package/src/proxy/mutation-detector.test.ts +71 -0
  140. package/src/proxy/mutation-detector.ts +227 -29
  141. package/src/proxy/oplog.ts +19 -10
  142. package/src/proxy/registry.ts +0 -33
  143. package/src/proxy/types.ts +2 -0
  144. package/src/pull.ts +788 -405
  145. package/src/push.ts +507 -312
  146. package/src/realtime/types.ts +2 -0
  147. package/src/schema.ts +11 -1
  148. package/src/snapshot-chunks/db-metadata.test.ts +169 -0
  149. package/src/snapshot-chunks/db-metadata.ts +347 -105
  150. package/src/snapshot-chunks/index.ts +0 -1
  151. package/src/snapshot-chunks/types.ts +31 -5
  152. package/src/snapshot-chunks.ts +60 -21
  153. package/src/subscriptions/resolve.ts +73 -18
  154. package/dist/shapes/create-handler.d.ts.map +0 -1
  155. package/dist/shapes/create-handler.js.map +0 -1
  156. package/dist/shapes/index.d.ts.map +0 -1
  157. package/dist/shapes/index.js +0 -4
  158. package/dist/shapes/index.js.map +0 -1
  159. package/dist/shapes/registry.d.ts.map +0 -1
  160. package/dist/shapes/registry.js.map +0 -1
  161. package/dist/snapshot-chunks/adapters/s3.d.ts +0 -63
  162. package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
  163. package/dist/snapshot-chunks/adapters/s3.js +0 -50
  164. package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
  165. package/src/snapshot-chunks/adapters/s3.ts +0 -68
  166. /package/dist/{shapes → handlers}/index.d.ts +0 -0
  167. /package/dist/{shapes → handlers}/registry.d.ts +0 -0
  168. /package/dist/{shapes → handlers}/registry.js +0 -0
  169. /package/dist/{shapes → handlers}/types.js +0 -0
  170. /package/src/{shapes → handlers}/index.ts +0 -0
  171. /package/src/{shapes → handlers}/registry.ts +0 -0
@@ -0,0 +1,516 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { createBunSqliteDb } from '../../dialect-bun-sqlite/src';
3
+ import { createSqliteServerDialect } from '../../server-dialect-sqlite/src';
4
+ import { createServerHandler } from './handlers';
5
+ import { TableRegistry } from './handlers/registry';
6
+ import { ensureSyncSchema } from './migrate';
7
+ import { EXTERNAL_CLIENT_ID, notifyExternalDataChange } from './notify';
8
+ import { pull } from './pull';
9
+ import type { SyncCoreDb } from './schema';
10
+
11
+ interface TasksTable {
12
+ id: string;
13
+ user_id: string;
14
+ title: string;
15
+ server_version: number;
16
+ }
17
+
18
+ interface CodesTable {
19
+ id: string;
20
+ catalog_id: string;
21
+ code: string;
22
+ label: string;
23
+ server_version: number;
24
+ }
25
+
26
+ interface TestDb extends SyncCoreDb {
27
+ tasks: TasksTable;
28
+ codes: CodesTable;
29
+ }
30
+
31
+ interface ClientDb {
32
+ tasks: TasksTable;
33
+ codes: CodesTable;
34
+ }
35
+
36
+ const dialect = createSqliteServerDialect();
37
+
38
+ async function setupDb() {
39
+ const db = createBunSqliteDb<TestDb>({ path: ':memory:' });
40
+ await ensureSyncSchema(db, dialect);
41
+
42
+ await db.schema
43
+ .createTable('tasks')
44
+ .addColumn('id', 'text', (col) => col.primaryKey())
45
+ .addColumn('user_id', 'text', (col) => col.notNull())
46
+ .addColumn('title', 'text', (col) => col.notNull())
47
+ .addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(0))
48
+ .execute();
49
+
50
+ await db.schema
51
+ .createTable('codes')
52
+ .addColumn('id', 'text', (col) => col.primaryKey())
53
+ .addColumn('catalog_id', 'text', (col) => col.notNull())
54
+ .addColumn('code', 'text', (col) => col.notNull())
55
+ .addColumn('label', 'text', (col) => col.notNull())
56
+ .addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(0))
57
+ .execute();
58
+
59
+ return db;
60
+ }
61
+
62
+ describe('notifyExternalDataChange', () => {
63
+ let db: ReturnType<typeof createBunSqliteDb<TestDb>>;
64
+
65
+ beforeEach(async () => {
66
+ db = await setupDb();
67
+ });
68
+
69
+ afterEach(async () => {
70
+ await db.destroy();
71
+ });
72
+
73
+ it('creates a synthetic commit with __external__ client_id', async () => {
74
+ const result = await notifyExternalDataChange({
75
+ db,
76
+ dialect,
77
+ tables: ['codes'],
78
+ });
79
+
80
+ expect(result.commitSeq).toBeGreaterThan(0);
81
+ expect(result.tables).toEqual(['codes']);
82
+
83
+ const commit = await db
84
+ .selectFrom('sync_commits')
85
+ .selectAll()
86
+ .where('commit_seq', '=', result.commitSeq)
87
+ .executeTakeFirstOrThrow();
88
+
89
+ expect(commit.client_id).toBe(EXTERNAL_CLIENT_ID);
90
+ expect(commit.actor_id).toBe(EXTERNAL_CLIENT_ID);
91
+ expect(commit.change_count).toBe(0);
92
+
93
+ const affectedTables = dialect.dbToArray(commit.affected_tables);
94
+ expect(affectedTables).toEqual(['codes']);
95
+ });
96
+
97
+ it('inserts sync_table_commits entries for each table', async () => {
98
+ const result = await notifyExternalDataChange({
99
+ db,
100
+ dialect,
101
+ tables: ['codes', 'tasks'],
102
+ });
103
+
104
+ const tableCommits = await db
105
+ .selectFrom('sync_table_commits')
106
+ .selectAll()
107
+ .where('commit_seq', '=', result.commitSeq)
108
+ .execute();
109
+
110
+ const tables = tableCommits.map((r) => r.table).sort();
111
+ expect(tables).toEqual(['codes', 'tasks']);
112
+ });
113
+
114
+ it('deletes cached snapshot chunks for affected tables', async () => {
115
+ // Insert fake snapshot chunks
116
+ await db
117
+ .insertInto('sync_snapshot_chunks')
118
+ .values({
119
+ chunk_id: 'chunk-1',
120
+ partition_id: 'default',
121
+ scope_key: 'test-key',
122
+ scope: 'codes',
123
+ as_of_commit_seq: 1,
124
+ row_cursor: '',
125
+ row_limit: 1000,
126
+ encoding: 'json-row-frame-v1',
127
+ compression: 'gzip',
128
+ sha256: 'abc',
129
+ byte_length: 100,
130
+ blob_hash: '',
131
+ body: new Uint8Array([1, 2, 3]),
132
+ expires_at: new Date(Date.now() + 86400000).toISOString(),
133
+ })
134
+ .execute();
135
+
136
+ await db
137
+ .insertInto('sync_snapshot_chunks')
138
+ .values({
139
+ chunk_id: 'chunk-2',
140
+ partition_id: 'default',
141
+ scope_key: 'test-key',
142
+ scope: 'tasks',
143
+ as_of_commit_seq: 1,
144
+ row_cursor: '',
145
+ row_limit: 1000,
146
+ encoding: 'json-row-frame-v1',
147
+ compression: 'gzip',
148
+ sha256: 'def',
149
+ byte_length: 200,
150
+ blob_hash: '',
151
+ body: new Uint8Array([4, 5, 6]),
152
+ expires_at: new Date(Date.now() + 86400000).toISOString(),
153
+ })
154
+ .execute();
155
+
156
+ const result = await notifyExternalDataChange({
157
+ db,
158
+ dialect,
159
+ tables: ['codes'],
160
+ });
161
+
162
+ expect(result.deletedChunks).toBe(1);
163
+
164
+ // 'codes' chunk should be deleted, 'tasks' chunk should remain
165
+ const remaining = await db
166
+ .selectFrom('sync_snapshot_chunks')
167
+ .selectAll()
168
+ .execute();
169
+ expect(remaining.length).toBe(1);
170
+ expect(remaining[0]!.scope).toBe('tasks');
171
+ });
172
+
173
+ it('throws on empty tables array', async () => {
174
+ await expect(
175
+ notifyExternalDataChange({ db, dialect, tables: [] })
176
+ ).rejects.toThrow('tables must not be empty');
177
+ });
178
+
179
+ it('uses custom partitionId and actorId', async () => {
180
+ const result = await notifyExternalDataChange({
181
+ db,
182
+ dialect,
183
+ tables: ['codes'],
184
+ partitionId: 'tenant-42',
185
+ actorId: 'pipeline-bot',
186
+ });
187
+
188
+ const commit = await db
189
+ .selectFrom('sync_commits')
190
+ .selectAll()
191
+ .where('commit_seq', '=', result.commitSeq)
192
+ .executeTakeFirstOrThrow();
193
+
194
+ expect(commit.partition_id).toBe('tenant-42');
195
+ expect(commit.actor_id).toBe('pipeline-bot');
196
+ expect(commit.client_id).toBe(EXTERNAL_CLIENT_ID);
197
+ });
198
+ });
199
+
200
+ describe('pull re-bootstrap after external data change', () => {
201
+ let db: ReturnType<typeof createBunSqliteDb<TestDb>>;
202
+
203
+ beforeEach(async () => {
204
+ db = await setupDb();
205
+ });
206
+
207
+ afterEach(async () => {
208
+ await db.destroy();
209
+ });
210
+
211
+ it('forces re-bootstrap for affected tables after notifyExternalDataChange', async () => {
212
+ // Seed some data
213
+ await db
214
+ .insertInto('codes')
215
+ .values({
216
+ id: 'c1',
217
+ catalog_id: 'icd',
218
+ code: 'A00',
219
+ label: 'Cholera',
220
+ server_version: 1,
221
+ })
222
+ .execute();
223
+
224
+ const codesHandler = createServerHandler<TestDb, ClientDb, 'codes'>({
225
+ table: 'codes',
226
+ scopes: ['catalog:{catalog_id}'],
227
+ resolveScopes: async () => ({ catalog_id: '*' }),
228
+ });
229
+
230
+ const handlers = new TableRegistry<TestDb>();
231
+ handlers.register(codesHandler);
232
+
233
+ // 1. Initial bootstrap pull
234
+ const firstPull = await pull({
235
+ db,
236
+ dialect,
237
+ handlers,
238
+ actorId: 'u1',
239
+ request: {
240
+ clientId: 'client-1',
241
+ limitCommits: 10,
242
+ limitSnapshotRows: 100,
243
+ maxSnapshotPages: 10,
244
+ subscriptions: [
245
+ {
246
+ id: 'sub-codes',
247
+ table: 'codes',
248
+ scopes: { catalog_id: 'icd' },
249
+ cursor: -1,
250
+ },
251
+ ],
252
+ },
253
+ });
254
+
255
+ const firstSub = firstPull.response.subscriptions[0]!;
256
+ expect(firstSub.bootstrap).toBe(true);
257
+ const cursorAfterBootstrap = firstSub.nextCursor;
258
+ expect(cursorAfterBootstrap).toBeGreaterThanOrEqual(0);
259
+
260
+ // 2. Incremental pull (should get no changes)
261
+ const incrementalPull = await pull({
262
+ db,
263
+ dialect,
264
+ handlers,
265
+ actorId: 'u1',
266
+ request: {
267
+ clientId: 'client-1',
268
+ limitCommits: 10,
269
+ limitSnapshotRows: 100,
270
+ maxSnapshotPages: 10,
271
+ subscriptions: [
272
+ {
273
+ id: 'sub-codes',
274
+ table: 'codes',
275
+ scopes: { catalog_id: 'icd' },
276
+ cursor: cursorAfterBootstrap,
277
+ },
278
+ ],
279
+ },
280
+ });
281
+
282
+ const incSub = incrementalPull.response.subscriptions[0]!;
283
+ expect(incSub.bootstrap).toBe(false);
284
+ expect(incSub.commits?.length ?? 0).toBe(0);
285
+
286
+ // 3. Notify external data change for 'codes'
287
+ const notifyResult = await notifyExternalDataChange({
288
+ db,
289
+ dialect,
290
+ tables: ['codes'],
291
+ });
292
+ expect(notifyResult.commitSeq).toBeGreaterThan(cursorAfterBootstrap);
293
+
294
+ // 4. Pull again with same cursor - should trigger re-bootstrap
295
+ const rebootstrapPull = await pull({
296
+ db,
297
+ dialect,
298
+ handlers,
299
+ actorId: 'u1',
300
+ request: {
301
+ clientId: 'client-1',
302
+ limitCommits: 10,
303
+ limitSnapshotRows: 100,
304
+ maxSnapshotPages: 10,
305
+ subscriptions: [
306
+ {
307
+ id: 'sub-codes',
308
+ table: 'codes',
309
+ scopes: { catalog_id: 'icd' },
310
+ cursor: cursorAfterBootstrap,
311
+ },
312
+ ],
313
+ },
314
+ });
315
+
316
+ const rebootSub = rebootstrapPull.response.subscriptions[0]!;
317
+ expect(rebootSub.bootstrap).toBe(true);
318
+ expect(rebootSub.snapshots?.length).toBeGreaterThan(0);
319
+ });
320
+
321
+ it('does not force re-bootstrap for unaffected tables', async () => {
322
+ await db
323
+ .insertInto('tasks')
324
+ .values({
325
+ id: 't1',
326
+ user_id: 'u1',
327
+ title: 'My Task',
328
+ server_version: 1,
329
+ })
330
+ .execute();
331
+
332
+ const tasksHandler = createServerHandler<TestDb, ClientDb, 'tasks'>({
333
+ table: 'tasks',
334
+ scopes: ['user:{user_id}'],
335
+ resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
336
+ });
337
+
338
+ const codesHandler = createServerHandler<TestDb, ClientDb, 'codes'>({
339
+ table: 'codes',
340
+ scopes: ['catalog:{catalog_id}'],
341
+ resolveScopes: async () => ({ catalog_id: '*' }),
342
+ });
343
+
344
+ const handlers = new TableRegistry<TestDb>();
345
+ handlers.register(tasksHandler);
346
+ handlers.register(codesHandler);
347
+
348
+ // 1. Bootstrap pull for tasks
349
+ const firstPull = await pull({
350
+ db,
351
+ dialect,
352
+ handlers,
353
+ actorId: 'u1',
354
+ request: {
355
+ clientId: 'client-1',
356
+ limitCommits: 10,
357
+ limitSnapshotRows: 100,
358
+ maxSnapshotPages: 10,
359
+ subscriptions: [
360
+ {
361
+ id: 'sub-tasks',
362
+ table: 'tasks',
363
+ scopes: { user_id: 'u1' },
364
+ cursor: -1,
365
+ },
366
+ ],
367
+ },
368
+ });
369
+
370
+ const cursorAfterBootstrap =
371
+ firstPull.response.subscriptions[0]!.nextCursor;
372
+
373
+ // 2. Notify external data change for 'codes' only (not tasks)
374
+ await notifyExternalDataChange({
375
+ db,
376
+ dialect,
377
+ tables: ['codes'],
378
+ });
379
+
380
+ // 3. Pull tasks again - should NOT trigger re-bootstrap
381
+ const pullAfterNotify = await pull({
382
+ db,
383
+ dialect,
384
+ handlers,
385
+ actorId: 'u1',
386
+ request: {
387
+ clientId: 'client-1',
388
+ limitCommits: 10,
389
+ limitSnapshotRows: 100,
390
+ maxSnapshotPages: 10,
391
+ subscriptions: [
392
+ {
393
+ id: 'sub-tasks',
394
+ table: 'tasks',
395
+ scopes: { user_id: 'u1' },
396
+ cursor: cursorAfterBootstrap,
397
+ },
398
+ ],
399
+ },
400
+ });
401
+
402
+ const tasksSub = pullAfterNotify.response.subscriptions[0]!;
403
+ expect(tasksSub.bootstrap).toBe(false);
404
+ });
405
+
406
+ it('forces re-bootstrap only for the affected table in a multi-subscription pull', async () => {
407
+ await db
408
+ .insertInto('tasks')
409
+ .values({
410
+ id: 't1',
411
+ user_id: 'u1',
412
+ title: 'My Task',
413
+ server_version: 1,
414
+ })
415
+ .execute();
416
+
417
+ await db
418
+ .insertInto('codes')
419
+ .values({
420
+ id: 'c1',
421
+ catalog_id: 'icd',
422
+ code: 'A00',
423
+ label: 'Cholera',
424
+ server_version: 1,
425
+ })
426
+ .execute();
427
+
428
+ const tasksHandler = createServerHandler<TestDb, ClientDb, 'tasks'>({
429
+ table: 'tasks',
430
+ scopes: ['user:{user_id}'],
431
+ resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
432
+ });
433
+
434
+ const codesHandler = createServerHandler<TestDb, ClientDb, 'codes'>({
435
+ table: 'codes',
436
+ scopes: ['catalog:{catalog_id}'],
437
+ resolveScopes: async () => ({ catalog_id: '*' }),
438
+ });
439
+
440
+ const handlers = new TableRegistry<TestDb>();
441
+ handlers.register(tasksHandler);
442
+ handlers.register(codesHandler);
443
+
444
+ // 1. Bootstrap both subscriptions
445
+ const firstPull = await pull({
446
+ db,
447
+ dialect,
448
+ handlers,
449
+ actorId: 'u1',
450
+ request: {
451
+ clientId: 'client-1',
452
+ limitCommits: 10,
453
+ limitSnapshotRows: 100,
454
+ maxSnapshotPages: 10,
455
+ subscriptions: [
456
+ {
457
+ id: 'sub-tasks',
458
+ table: 'tasks',
459
+ scopes: { user_id: 'u1' },
460
+ cursor: -1,
461
+ },
462
+ {
463
+ id: 'sub-codes',
464
+ table: 'codes',
465
+ scopes: { catalog_id: 'icd' },
466
+ cursor: -1,
467
+ },
468
+ ],
469
+ },
470
+ });
471
+
472
+ const tasksCursor = firstPull.response.subscriptions[0]!.nextCursor;
473
+ const codesCursor = firstPull.response.subscriptions[1]!.nextCursor;
474
+
475
+ // 2. Notify external data change for 'codes' only
476
+ await notifyExternalDataChange({ db, dialect, tables: ['codes'] });
477
+
478
+ // 3. Pull both subscriptions
479
+ const pullAfter = await pull({
480
+ db,
481
+ dialect,
482
+ handlers,
483
+ actorId: 'u1',
484
+ request: {
485
+ clientId: 'client-1',
486
+ limitCommits: 10,
487
+ limitSnapshotRows: 100,
488
+ maxSnapshotPages: 10,
489
+ subscriptions: [
490
+ {
491
+ id: 'sub-tasks',
492
+ table: 'tasks',
493
+ scopes: { user_id: 'u1' },
494
+ cursor: tasksCursor,
495
+ },
496
+ {
497
+ id: 'sub-codes',
498
+ table: 'codes',
499
+ scopes: { catalog_id: 'icd' },
500
+ cursor: codesCursor,
501
+ },
502
+ ],
503
+ },
504
+ });
505
+
506
+ const tasksSub = pullAfter.response.subscriptions.find(
507
+ (s) => s.id === 'sub-tasks'
508
+ )!;
509
+ const codesSub = pullAfter.response.subscriptions.find(
510
+ (s) => s.id === 'sub-codes'
511
+ )!;
512
+
513
+ expect(tasksSub.bootstrap).toBe(false);
514
+ expect(codesSub.bootstrap).toBe(true);
515
+ });
516
+ });
package/src/notify.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @syncular/server - External data change notification
3
+ *
4
+ * Creates synthetic commits to notify the sync framework about data changes
5
+ * made outside the normal push flow (e.g., pipeline imports, direct DB writes).
6
+ *
7
+ * The synthetic commit forces affected subscriptions to re-bootstrap on next pull,
8
+ * ensuring clients receive the updated data.
9
+ */
10
+
11
+ import { randomUUID } from 'node:crypto';
12
+ import type { Insertable, Kysely } from 'kysely';
13
+ import type { ServerSyncDialect } from './dialect/types';
14
+ import type { SyncCoreDb } from './schema';
15
+
16
+ /**
17
+ * Well-known client_id for external/synthetic commits.
18
+ * Used by the pull handler to detect external data changes.
19
+ */
20
+ export const EXTERNAL_CLIENT_ID = '__external__';
21
+
22
+ export interface NotifyExternalDataChangeArgs<DB extends SyncCoreDb> {
23
+ db: Kysely<DB>;
24
+ dialect: ServerSyncDialect;
25
+ /** Table names that were externally modified. */
26
+ tables: string[];
27
+ /** Partition key. Defaults to 'default'. */
28
+ partitionId?: string;
29
+ /** Actor identifier for the synthetic commit. Defaults to '__external__'. */
30
+ actorId?: string;
31
+ }
32
+
33
+ export interface NotifyExternalDataChangeResult {
34
+ /** The commit_seq of the synthetic commit. */
35
+ commitSeq: number;
36
+ /** Tables that were notified. */
37
+ tables: string[];
38
+ /** Number of snapshot chunks deleted. */
39
+ deletedChunks: number;
40
+ }
41
+
42
+ /**
43
+ * Notify the sync framework about external data changes.
44
+ *
45
+ * Inserts a synthetic commit (client_id = '__external__'), clears cached
46
+ * snapshot chunks for the affected tables, and inserts sync_table_commits
47
+ * entries so the pull handler can detect the change.
48
+ *
49
+ * On next pull, subscriptions for affected tables will trigger a re-bootstrap
50
+ * instead of an incremental pull.
51
+ */
52
+ export async function notifyExternalDataChange<DB extends SyncCoreDb>(
53
+ args: NotifyExternalDataChangeArgs<DB>
54
+ ): Promise<NotifyExternalDataChangeResult> {
55
+ const { db, dialect, tables } = args;
56
+ const partitionId = args.partitionId ?? 'default';
57
+ const actorId = args.actorId ?? EXTERNAL_CLIENT_ID;
58
+
59
+ if (tables.length === 0) {
60
+ throw new Error('notifyExternalDataChange: tables must not be empty');
61
+ }
62
+
63
+ return dialect.executeInTransaction(db, async (trx) => {
64
+ type SyncTrx = Pick<
65
+ Kysely<SyncCoreDb>,
66
+ 'selectFrom' | 'insertInto' | 'updateTable' | 'deleteFrom'
67
+ >;
68
+ const syncTrx = trx as SyncTrx;
69
+
70
+ const clientCommitId = `ext_${Date.now()}_${randomUUID()}`;
71
+
72
+ // 1. Insert synthetic commit
73
+ const commitRow: Insertable<SyncCoreDb['sync_commits']> = {
74
+ partition_id: partitionId,
75
+ actor_id: actorId,
76
+ client_id: EXTERNAL_CLIENT_ID,
77
+ client_commit_id: clientCommitId,
78
+ meta: null,
79
+ result_json: { ok: true, status: 'applied' },
80
+ change_count: 0,
81
+ affected_tables: dialect.arrayToDb(tables) as string[],
82
+ };
83
+
84
+ await syncTrx.insertInto('sync_commits').values(commitRow).execute();
85
+
86
+ // Read back the assigned commit_seq
87
+ const inserted = await syncTrx
88
+ .selectFrom('sync_commits')
89
+ .select(['commit_seq'])
90
+ .where('partition_id', '=', partitionId)
91
+ .where('client_id', '=', EXTERNAL_CLIENT_ID)
92
+ .where('client_commit_id', '=', clientCommitId)
93
+ .executeTakeFirstOrThrow();
94
+
95
+ const commitSeq = Number(inserted.commit_seq);
96
+
97
+ // 2. Insert sync_table_commits entries for each affected table
98
+ const tableCommits: Array<Insertable<SyncCoreDb['sync_table_commits']>> =
99
+ tables.map((table) => ({
100
+ partition_id: partitionId,
101
+ table,
102
+ commit_seq: commitSeq,
103
+ }));
104
+
105
+ await syncTrx
106
+ .insertInto('sync_table_commits')
107
+ .values(tableCommits)
108
+ .onConflict((oc) =>
109
+ oc.columns(['partition_id', 'table', 'commit_seq']).doNothing()
110
+ )
111
+ .execute();
112
+
113
+ // 3. Delete cached snapshot chunks for affected tables
114
+ let deletedChunks = 0;
115
+ for (const table of tables) {
116
+ const result = await syncTrx
117
+ .deleteFrom('sync_snapshot_chunks')
118
+ .where('partition_id', '=', partitionId)
119
+ .where('scope', '=', table)
120
+ .executeTakeFirst();
121
+
122
+ deletedChunks += Number(result?.numDeletedRows ?? 0);
123
+ }
124
+
125
+ return {
126
+ commitSeq,
127
+ tables,
128
+ deletedChunks,
129
+ };
130
+ });
131
+ }