@syncular/server 0.0.1-98 → 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.
- package/README.md +25 -0
- package/dist/blobs/adapters/filesystem.d.ts +31 -0
- package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
- package/dist/blobs/adapters/filesystem.js +140 -0
- package/dist/blobs/adapters/filesystem.js.map +1 -0
- package/dist/blobs/adapters/s3.d.ts +3 -2
- package/dist/blobs/adapters/s3.d.ts.map +1 -1
- package/dist/blobs/adapters/s3.js +49 -0
- package/dist/blobs/adapters/s3.js.map +1 -1
- package/dist/blobs/index.d.ts +1 -0
- package/dist/blobs/index.d.ts.map +1 -1
- package/dist/blobs/index.js +1 -0
- package/dist/blobs/index.js.map +1 -1
- package/dist/compaction.d.ts +1 -1
- package/dist/compaction.js +1 -1
- package/dist/{shapes → handlers}/create-handler.d.ts +18 -5
- package/dist/handlers/create-handler.d.ts.map +1 -0
- package/dist/{shapes → handlers}/create-handler.js +54 -17
- package/dist/handlers/create-handler.js.map +1 -0
- package/dist/handlers/index.d.ts.map +1 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/registry.d.ts.map +1 -0
- package/dist/handlers/registry.js.map +1 -0
- package/dist/{shapes → handlers}/types.d.ts +7 -7
- package/dist/{shapes → handlers}/types.d.ts.map +1 -1
- package/dist/{shapes → handlers}/types.js.map +1 -1
- package/dist/helpers/conflict.d.ts +1 -1
- package/dist/helpers/conflict.d.ts.map +1 -1
- package/dist/helpers/emitted-change.d.ts +1 -1
- package/dist/helpers/emitted-change.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/notify.d.ts +47 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +85 -0
- package/dist/notify.js.map +1 -0
- package/dist/proxy/handler.d.ts +1 -1
- package/dist/proxy/handler.d.ts.map +1 -1
- package/dist/proxy/handler.js +7 -7
- package/dist/proxy/handler.js.map +1 -1
- package/dist/proxy/oplog.d.ts +1 -1
- package/dist/proxy/oplog.d.ts.map +1 -1
- package/dist/proxy/oplog.js +6 -6
- package/dist/proxy/oplog.js.map +1 -1
- package/dist/pull.d.ts +2 -2
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +48 -9
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +2 -2
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +1 -1
- package/dist/push.js.map +1 -1
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.js +14 -3
- package/dist/snapshot-chunks/db-metadata.js.map +1 -1
- package/dist/snapshot-chunks/index.d.ts +0 -1
- package/dist/snapshot-chunks/index.d.ts.map +1 -1
- package/dist/snapshot-chunks/index.js +0 -1
- package/dist/snapshot-chunks/index.js.map +1 -1
- package/dist/subscriptions/resolve.d.ts +6 -6
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +53 -14
- package/dist/subscriptions/resolve.js.map +1 -1
- package/package.json +2 -2
- package/src/blobs/adapters/filesystem.test.ts +132 -0
- package/src/blobs/adapters/filesystem.ts +189 -0
- package/src/blobs/adapters/s3.test.ts +522 -0
- package/src/blobs/adapters/s3.ts +55 -2
- package/src/blobs/index.ts +1 -0
- package/src/compaction.ts +1 -1
- package/src/{shapes → handlers}/create-handler.ts +111 -21
- package/src/{shapes → handlers}/types.ts +10 -7
- package/src/helpers/conflict.ts +1 -1
- package/src/helpers/emitted-change.ts +1 -1
- package/src/index.ts +2 -1
- package/src/notify.test.ts +516 -0
- package/src/notify.ts +131 -0
- package/src/proxy/handler.test.ts +3 -3
- package/src/proxy/handler.ts +8 -8
- package/src/proxy/oplog.ts +7 -7
- package/src/pull.ts +66 -12
- package/src/push.ts +3 -3
- package/src/snapshot-chunks/db-metadata.test.ts +69 -0
- package/src/snapshot-chunks/db-metadata.ts +14 -3
- package/src/snapshot-chunks/index.ts +0 -1
- package/src/subscriptions/resolve.ts +73 -18
- package/dist/shapes/create-handler.d.ts.map +0 -1
- package/dist/shapes/create-handler.js.map +0 -1
- package/dist/shapes/index.d.ts.map +0 -1
- package/dist/shapes/index.js.map +0 -1
- package/dist/shapes/registry.d.ts.map +0 -1
- package/dist/shapes/registry.js.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.d.ts +0 -74
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
- package/dist/snapshot-chunks/adapters/s3.js +0 -50
- package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
- package/src/snapshot-chunks/adapters/s3.ts +0 -68
- /package/dist/{shapes → handlers}/index.d.ts +0 -0
- /package/dist/{shapes → handlers}/index.js +0 -0
- /package/dist/{shapes → handlers}/registry.d.ts +0 -0
- /package/dist/{shapes → handlers}/registry.js +0 -0
- /package/dist/{shapes → handlers}/types.js +0 -0
- /package/src/{shapes → handlers}/index.ts +0 -0
- /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
|
+
}
|
|
@@ -21,7 +21,7 @@ interface ProxyTestDb extends SyncCoreDb {
|
|
|
21
21
|
describe('executeProxyQuery', () => {
|
|
22
22
|
let db: Kysely<ProxyTestDb>;
|
|
23
23
|
const dialect = createSqliteServerDialect();
|
|
24
|
-
const
|
|
24
|
+
const handlers = new ProxyTableRegistry().register({
|
|
25
25
|
table: 'tasks',
|
|
26
26
|
computeScopes: (row) => ({
|
|
27
27
|
user_id: String(row.user_id),
|
|
@@ -59,7 +59,7 @@ describe('executeProxyQuery', () => {
|
|
|
59
59
|
const result = await executeProxyQuery({
|
|
60
60
|
db,
|
|
61
61
|
dialect,
|
|
62
|
-
|
|
62
|
+
handlers,
|
|
63
63
|
ctx: { actorId: 'actor-1', clientId: 'proxy-client-1' },
|
|
64
64
|
sqlQuery:
|
|
65
65
|
'/* admin */ UPDATE tasks SET title = $1, server_version = server_version + 1 WHERE id = $2',
|
|
@@ -95,7 +95,7 @@ describe('executeProxyQuery', () => {
|
|
|
95
95
|
executeProxyQuery({
|
|
96
96
|
db,
|
|
97
97
|
dialect,
|
|
98
|
-
|
|
98
|
+
handlers,
|
|
99
99
|
ctx: { actorId: 'actor-1', clientId: 'proxy-client-1' },
|
|
100
100
|
sqlQuery: 'UPDATE tasks SET title = $1 WHERE id = $2 RETURNING id',
|
|
101
101
|
parameters: ['blocked title', 't1'],
|