@syncular/client 0.0.6-185 → 0.0.6-188

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 (81) hide show
  1. package/dist/client.d.ts +14 -71
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +81 -406
  4. package/dist/client.js.map +1 -1
  5. package/dist/create-client.d.ts +0 -2
  6. package/dist/create-client.d.ts.map +1 -1
  7. package/dist/create-client.js +2 -3
  8. package/dist/create-client.js.map +1 -1
  9. package/dist/engine/SyncEngine.d.ts +1 -0
  10. package/dist/engine/SyncEngine.d.ts.map +1 -1
  11. package/dist/engine/SyncEngine.js +21 -6
  12. package/dist/engine/SyncEngine.js.map +1 -1
  13. package/dist/handlers/create-handler.d.ts +5 -0
  14. package/dist/handlers/create-handler.d.ts.map +1 -1
  15. package/dist/handlers/create-handler.js +123 -4
  16. package/dist/handlers/create-handler.js.map +1 -1
  17. package/dist/handlers/types.d.ts +7 -0
  18. package/dist/handlers/types.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +0 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/internal/blob-schema.d.ts +32 -0
  24. package/dist/internal/blob-schema.d.ts.map +1 -0
  25. package/dist/internal/blob-schema.js +2 -0
  26. package/dist/internal/blob-schema.js.map +1 -0
  27. package/dist/mutations.d.ts.map +1 -1
  28. package/dist/mutations.js +15 -6
  29. package/dist/mutations.js.map +1 -1
  30. package/dist/plugins/incrementing-version.d.ts.map +1 -1
  31. package/dist/plugins/incrementing-version.js +20 -8
  32. package/dist/plugins/incrementing-version.js.map +1 -1
  33. package/dist/plugins/types.d.ts +26 -1
  34. package/dist/plugins/types.d.ts.map +1 -1
  35. package/dist/plugins/types.js.map +1 -1
  36. package/dist/pull-engine.d.ts +8 -2
  37. package/dist/pull-engine.d.ts.map +1 -1
  38. package/dist/pull-engine.js +150 -26
  39. package/dist/pull-engine.js.map +1 -1
  40. package/dist/push-engine.d.ts.map +1 -1
  41. package/dist/push-engine.js +21 -5
  42. package/dist/push-engine.js.map +1 -1
  43. package/dist/schema.d.ts +2 -2
  44. package/dist/schema.d.ts.map +1 -1
  45. package/dist/sync-loop.d.ts +3 -1
  46. package/dist/sync-loop.d.ts.map +1 -1
  47. package/dist/sync-loop.js +382 -139
  48. package/dist/sync-loop.js.map +1 -1
  49. package/package.json +76 -3
  50. package/src/client.test.ts +72 -155
  51. package/src/client.ts +113 -572
  52. package/src/create-client.ts +1 -6
  53. package/src/engine/SyncEngine.test.ts +90 -0
  54. package/src/engine/SyncEngine.ts +29 -9
  55. package/src/handlers/create-handler.ts +197 -4
  56. package/src/handlers/types.ts +11 -0
  57. package/src/index.ts +1 -2
  58. package/src/internal/blob-schema.ts +40 -0
  59. package/src/mutations.ts +17 -6
  60. package/src/plugins/incrementing-version.ts +36 -7
  61. package/src/plugins/types.ts +42 -0
  62. package/src/pull-engine.test.ts +494 -0
  63. package/src/pull-engine.ts +193 -29
  64. package/src/push-engine.ts +31 -5
  65. package/src/schema.ts +2 -2
  66. package/src/sync-loop.ts +538 -145
  67. package/dist/blobs/index.d.ts +0 -6
  68. package/dist/blobs/index.d.ts.map +0 -1
  69. package/dist/blobs/index.js +0 -6
  70. package/dist/blobs/index.js.map +0 -1
  71. package/dist/blobs/migrate.d.ts +0 -14
  72. package/dist/blobs/migrate.d.ts.map +0 -1
  73. package/dist/blobs/migrate.js +0 -59
  74. package/dist/blobs/migrate.js.map +0 -1
  75. package/dist/blobs/types.d.ts +0 -62
  76. package/dist/blobs/types.d.ts.map +0 -1
  77. package/dist/blobs/types.js +0 -5
  78. package/dist/blobs/types.js.map +0 -1
  79. package/src/blobs/index.ts +0 -6
  80. package/src/blobs/migrate.ts +0 -67
  81. package/src/blobs/types.ts +0 -84
@@ -3,6 +3,7 @@ import { gzipSync } from 'node:zlib';
3
3
  import {
4
4
  createDatabase,
5
5
  encodeSnapshotRows,
6
+ type ScopeValues,
6
7
  type SyncPullResponse,
7
8
  type SyncTransport,
8
9
  } from '@syncular/core';
@@ -19,8 +20,15 @@ interface ItemsTable {
19
20
  name: string;
20
21
  }
21
22
 
23
+ interface ScopedItemsTable {
24
+ id: string;
25
+ project_id: string;
26
+ name: string;
27
+ }
28
+
22
29
  interface TestDb extends SyncClientDb {
23
30
  items: ItemsTable;
31
+ scoped_items: ScopedItemsTable;
24
32
  }
25
33
 
26
34
  function createStreamFromBytes(
@@ -37,6 +45,11 @@ function createStreamFromBytes(
37
45
  });
38
46
  }
39
47
 
48
+ function toScopeValueArray(value: string | string[] | undefined): string[] {
49
+ if (!value) return [];
50
+ return Array.isArray(value) ? value : [value];
51
+ }
52
+
40
53
  describe('applyPullResponse chunk streaming', () => {
41
54
  let db: Kysely<TestDb>;
42
55
 
@@ -1048,4 +1061,485 @@ describe('applyPullResponse chunk streaming', () => {
1048
1061
  },
1049
1062
  ]);
1050
1063
  });
1064
+
1065
+ it('uses applyChanges for contiguous same-table incremental changes', async () => {
1066
+ const transport: SyncTransport = {
1067
+ async sync() {
1068
+ return {};
1069
+ },
1070
+ async fetchSnapshotChunk() {
1071
+ return new Uint8Array();
1072
+ },
1073
+ };
1074
+
1075
+ let applyChangeCalls = 0;
1076
+ let applyChangesCalls = 0;
1077
+ const baseHandler = createClientHandler<TestDb, 'items'>({
1078
+ table: 'items',
1079
+ scopes: ['items:{id}'],
1080
+ });
1081
+
1082
+ const handlers: ClientHandlerCollection<TestDb> = [
1083
+ {
1084
+ ...baseHandler,
1085
+ async applyChange(ctx, change) {
1086
+ applyChangeCalls += 1;
1087
+ await baseHandler.applyChange(ctx, change);
1088
+ },
1089
+ async applyChanges(ctx, changes) {
1090
+ applyChangesCalls += 1;
1091
+ if (!baseHandler.applyChanges) {
1092
+ throw new Error('Expected applyChanges to be available');
1093
+ }
1094
+ await baseHandler.applyChanges(ctx, changes);
1095
+ },
1096
+ },
1097
+ ];
1098
+
1099
+ const options = {
1100
+ clientId: 'client-1',
1101
+ subscriptions: [
1102
+ {
1103
+ id: 'items-sub',
1104
+ table: 'items',
1105
+ scopes: {},
1106
+ },
1107
+ ],
1108
+ stateId: 'default',
1109
+ };
1110
+
1111
+ const pullState = await buildPullRequest(db, options);
1112
+ const response: SyncPullResponse = {
1113
+ ok: true,
1114
+ subscriptions: [
1115
+ {
1116
+ id: 'items-sub',
1117
+ status: 'active',
1118
+ scopes: {},
1119
+ bootstrap: false,
1120
+ bootstrapState: null,
1121
+ nextCursor: 9,
1122
+ commits: [
1123
+ {
1124
+ commitSeq: 9,
1125
+ actorId: 'remote-user',
1126
+ createdAt: '2026-03-01T12:00:00.000Z',
1127
+ changes: [
1128
+ {
1129
+ table: 'items',
1130
+ row_id: 'item-1',
1131
+ op: 'upsert',
1132
+ row_version: 1,
1133
+ row_json: { id: 'item-1', name: 'One' },
1134
+ scopes: {},
1135
+ },
1136
+ {
1137
+ table: 'items',
1138
+ row_id: 'item-2',
1139
+ op: 'upsert',
1140
+ row_version: 1,
1141
+ row_json: { id: 'item-2', name: 'Two' },
1142
+ scopes: {},
1143
+ },
1144
+ ],
1145
+ },
1146
+ ],
1147
+ snapshots: [],
1148
+ },
1149
+ ],
1150
+ };
1151
+
1152
+ await applyPullResponse(
1153
+ db,
1154
+ transport,
1155
+ handlers,
1156
+ options,
1157
+ pullState,
1158
+ response
1159
+ );
1160
+
1161
+ expect(applyChangesCalls).toBe(1);
1162
+ expect(applyChangeCalls).toBe(0);
1163
+ });
1164
+
1165
+ it('flushes batched upserts when the same row appears twice in one commit', async () => {
1166
+ const transport: SyncTransport = {
1167
+ async sync() {
1168
+ return {};
1169
+ },
1170
+ async fetchSnapshotChunk() {
1171
+ return new Uint8Array();
1172
+ },
1173
+ };
1174
+
1175
+ const handlers: ClientHandlerCollection<TestDb> = [
1176
+ createClientHandler({
1177
+ table: 'items',
1178
+ scopes: ['items:{id}'],
1179
+ }),
1180
+ ];
1181
+
1182
+ const options = {
1183
+ clientId: 'client-1',
1184
+ subscriptions: [
1185
+ {
1186
+ id: 'items-sub',
1187
+ table: 'items',
1188
+ scopes: {},
1189
+ },
1190
+ ],
1191
+ stateId: 'default',
1192
+ };
1193
+
1194
+ const pullState = await buildPullRequest(db, options);
1195
+ const response: SyncPullResponse = {
1196
+ ok: true,
1197
+ subscriptions: [
1198
+ {
1199
+ id: 'items-sub',
1200
+ status: 'active',
1201
+ scopes: {},
1202
+ bootstrap: false,
1203
+ bootstrapState: null,
1204
+ nextCursor: 10,
1205
+ commits: [
1206
+ {
1207
+ commitSeq: 10,
1208
+ actorId: 'remote-user',
1209
+ createdAt: '2026-03-01T12:00:00.000Z',
1210
+ changes: [
1211
+ {
1212
+ table: 'items',
1213
+ row_id: 'item-1',
1214
+ op: 'upsert',
1215
+ row_version: 1,
1216
+ row_json: { id: 'item-1', name: 'One' },
1217
+ scopes: {},
1218
+ },
1219
+ {
1220
+ table: 'items',
1221
+ row_id: 'item-1',
1222
+ op: 'upsert',
1223
+ row_version: 2,
1224
+ row_json: { id: 'item-1', name: 'Two' },
1225
+ scopes: {},
1226
+ },
1227
+ ],
1228
+ },
1229
+ ],
1230
+ snapshots: [],
1231
+ },
1232
+ ],
1233
+ };
1234
+
1235
+ await applyPullResponse(
1236
+ db,
1237
+ transport,
1238
+ handlers,
1239
+ options,
1240
+ pullState,
1241
+ response
1242
+ );
1243
+
1244
+ const row = await db
1245
+ .selectFrom('items')
1246
+ .select(['id', 'name'])
1247
+ .where('id', '=', 'item-1')
1248
+ .executeTakeFirstOrThrow();
1249
+
1250
+ expect(row.name).toBe('Two');
1251
+ });
1252
+
1253
+ it('clears stale rows during same-scope bootstrap by default', async () => {
1254
+ const transport: SyncTransport = {
1255
+ async sync() {
1256
+ return {};
1257
+ },
1258
+ async fetchSnapshotChunk() {
1259
+ throw new Error('fetchSnapshotChunk should not be used');
1260
+ },
1261
+ };
1262
+
1263
+ await db.schema
1264
+ .createTable('scoped_items')
1265
+ .addColumn('id', 'text', (col) => col.primaryKey())
1266
+ .addColumn('project_id', 'text', (col) => col.notNull())
1267
+ .addColumn('name', 'text', (col) => col.notNull())
1268
+ .execute();
1269
+
1270
+ const handlers: ClientHandlerCollection<TestDb> = [
1271
+ createClientHandler({
1272
+ table: 'scoped_items',
1273
+ scopes: ['project:{project_id}'],
1274
+ }),
1275
+ ];
1276
+
1277
+ const options = {
1278
+ clientId: 'client-1',
1279
+ subscriptions: [
1280
+ {
1281
+ id: 'scoped-sub',
1282
+ table: 'scoped_items',
1283
+ scopes: { project_id: 'p1' },
1284
+ },
1285
+ ],
1286
+ stateId: 'default',
1287
+ };
1288
+
1289
+ const firstState = await buildPullRequest(db, options);
1290
+ await applyPullResponse(db, transport, handlers, options, firstState, {
1291
+ ok: true,
1292
+ subscriptions: [
1293
+ {
1294
+ id: 'scoped-sub',
1295
+ status: 'active',
1296
+ scopes: { project_id: 'p1' },
1297
+ bootstrap: true,
1298
+ bootstrapState: null,
1299
+ nextCursor: 1,
1300
+ commits: [],
1301
+ snapshots: [
1302
+ {
1303
+ table: 'scoped_items',
1304
+ rows: [
1305
+ { id: 'p1-a', project_id: 'p1', name: 'A' },
1306
+ { id: 'p1-b', project_id: 'p1', name: 'B' },
1307
+ ],
1308
+ isFirstPage: true,
1309
+ isLastPage: true,
1310
+ },
1311
+ ],
1312
+ },
1313
+ ],
1314
+ });
1315
+
1316
+ const secondState = await buildPullRequest(db, options);
1317
+ await applyPullResponse(db, transport, handlers, options, secondState, {
1318
+ ok: true,
1319
+ subscriptions: [
1320
+ {
1321
+ id: 'scoped-sub',
1322
+ status: 'active',
1323
+ scopes: { project_id: 'p1' },
1324
+ bootstrap: true,
1325
+ bootstrapState: null,
1326
+ nextCursor: 2,
1327
+ commits: [],
1328
+ snapshots: [
1329
+ {
1330
+ table: 'scoped_items',
1331
+ rows: [{ id: 'p1-a', project_id: 'p1', name: 'A' }],
1332
+ isFirstPage: true,
1333
+ isLastPage: true,
1334
+ },
1335
+ ],
1336
+ },
1337
+ ],
1338
+ });
1339
+
1340
+ const rows = await db
1341
+ .selectFrom('scoped_items')
1342
+ .select(['id'])
1343
+ .orderBy('id', 'asc')
1344
+ .execute();
1345
+ expect(rows.map((row) => row.id)).toEqual(['p1-a']);
1346
+ });
1347
+
1348
+ it('clears previously authorized rows when bootstrap scopes narrow', async () => {
1349
+ const transport: SyncTransport = {
1350
+ async sync() {
1351
+ return {};
1352
+ },
1353
+ async fetchSnapshotChunk() {
1354
+ throw new Error('fetchSnapshotChunk should not be used');
1355
+ },
1356
+ };
1357
+
1358
+ await db.schema
1359
+ .createTable('scoped_items')
1360
+ .addColumn('id', 'text', (col) => col.primaryKey())
1361
+ .addColumn('project_id', 'text', (col) => col.notNull())
1362
+ .addColumn('name', 'text', (col) => col.notNull())
1363
+ .execute();
1364
+
1365
+ const handlers: ClientHandlerCollection<TestDb> = [
1366
+ createClientHandler({
1367
+ table: 'scoped_items',
1368
+ scopes: ['project:{project_id}'],
1369
+ }),
1370
+ ];
1371
+
1372
+ const options = {
1373
+ clientId: 'client-1',
1374
+ subscriptions: [
1375
+ {
1376
+ id: 'scoped-sub',
1377
+ table: 'scoped_items',
1378
+ scopes: { project_id: ['p1', 'p2'] },
1379
+ },
1380
+ ],
1381
+ stateId: 'default',
1382
+ };
1383
+
1384
+ const firstState = await buildPullRequest(db, options);
1385
+ await applyPullResponse(db, transport, handlers, options, firstState, {
1386
+ ok: true,
1387
+ subscriptions: [
1388
+ {
1389
+ id: 'scoped-sub',
1390
+ status: 'active',
1391
+ scopes: { project_id: ['p1', 'p2'] },
1392
+ bootstrap: true,
1393
+ bootstrapState: null,
1394
+ nextCursor: 1,
1395
+ commits: [],
1396
+ snapshots: [
1397
+ {
1398
+ table: 'scoped_items',
1399
+ rows: [
1400
+ { id: 'p1-a', project_id: 'p1', name: 'A' },
1401
+ { id: 'p2-a', project_id: 'p2', name: 'B' },
1402
+ ],
1403
+ isFirstPage: true,
1404
+ isLastPage: true,
1405
+ },
1406
+ ],
1407
+ },
1408
+ ],
1409
+ });
1410
+
1411
+ const secondState = await buildPullRequest(db, options);
1412
+ await applyPullResponse(db, transport, handlers, options, secondState, {
1413
+ ok: true,
1414
+ subscriptions: [
1415
+ {
1416
+ id: 'scoped-sub',
1417
+ status: 'active',
1418
+ scopes: { project_id: 'p2' },
1419
+ bootstrap: true,
1420
+ bootstrapState: null,
1421
+ nextCursor: 2,
1422
+ commits: [],
1423
+ snapshots: [
1424
+ {
1425
+ table: 'scoped_items',
1426
+ rows: [{ id: 'p2-a', project_id: 'p2', name: 'B' }],
1427
+ isFirstPage: true,
1428
+ isLastPage: true,
1429
+ },
1430
+ ],
1431
+ },
1432
+ ],
1433
+ });
1434
+
1435
+ const rows = await db
1436
+ .selectFrom('scoped_items')
1437
+ .select(['id'])
1438
+ .orderBy('id', 'asc')
1439
+ .execute();
1440
+ expect(rows.map((row) => row.id)).toEqual(['p2-a']);
1441
+ });
1442
+
1443
+ it('clears only the removed scope slice when bootstrap scopes narrow on one key', async () => {
1444
+ const transport: SyncTransport = {
1445
+ async sync() {
1446
+ return {};
1447
+ },
1448
+ async fetchSnapshotChunk() {
1449
+ throw new Error('fetchSnapshotChunk should not be used');
1450
+ },
1451
+ };
1452
+
1453
+ await db.schema
1454
+ .createTable('tracked_items')
1455
+ .addColumn('id', 'text', (col) => col.primaryKey())
1456
+ .addColumn('project_id', 'text', (col) => col.notNull())
1457
+ .addColumn('name', 'text', (col) => col.notNull())
1458
+ .execute();
1459
+
1460
+ const clearedScopes: ScopeValues[] = [];
1461
+
1462
+ const handlers: ClientHandlerCollection<TestDb> = [
1463
+ createClientHandler({
1464
+ table: 'tracked_items',
1465
+ scopes: ['project:{project_id}'],
1466
+ clearAll: async (ctx) => {
1467
+ clearedScopes.push(ctx.scopes);
1468
+ await sql`
1469
+ delete from ${sql.table('tracked_items')}
1470
+ where ${sql.ref('project_id')} in ${sql`(${sql.join(
1471
+ toScopeValueArray(ctx.scopes.project_id).map((value) =>
1472
+ sql.val(value)
1473
+ )
1474
+ )})`}
1475
+ `.execute(ctx.trx);
1476
+ },
1477
+ }),
1478
+ ];
1479
+
1480
+ const options = {
1481
+ clientId: 'client-1',
1482
+ subscriptions: [
1483
+ {
1484
+ id: 'tracked-sub',
1485
+ table: 'tracked_items',
1486
+ scopes: { project_id: ['p1', 'p2'] },
1487
+ },
1488
+ ],
1489
+ stateId: 'default',
1490
+ };
1491
+
1492
+ const firstState = await buildPullRequest(db, options);
1493
+ await applyPullResponse(db, transport, handlers, options, firstState, {
1494
+ ok: true,
1495
+ subscriptions: [
1496
+ {
1497
+ id: 'tracked-sub',
1498
+ status: 'active',
1499
+ scopes: { project_id: ['p1', 'p2'] },
1500
+ bootstrap: true,
1501
+ bootstrapState: null,
1502
+ nextCursor: 1,
1503
+ commits: [],
1504
+ snapshots: [
1505
+ {
1506
+ table: 'tracked_items',
1507
+ rows: [
1508
+ { id: 'p1-a', project_id: 'p1', name: 'A' },
1509
+ { id: 'p2-a', project_id: 'p2', name: 'B' },
1510
+ ],
1511
+ isFirstPage: true,
1512
+ isLastPage: true,
1513
+ },
1514
+ ],
1515
+ },
1516
+ ],
1517
+ });
1518
+
1519
+ const secondState = await buildPullRequest(db, options);
1520
+ await applyPullResponse(db, transport, handlers, options, secondState, {
1521
+ ok: true,
1522
+ subscriptions: [
1523
+ {
1524
+ id: 'tracked-sub',
1525
+ status: 'active',
1526
+ scopes: { project_id: 'p2' },
1527
+ bootstrap: true,
1528
+ bootstrapState: null,
1529
+ nextCursor: 2,
1530
+ commits: [],
1531
+ snapshots: [
1532
+ {
1533
+ table: 'tracked_items',
1534
+ rows: [{ id: 'p2-a', project_id: 'p2', name: 'B' }],
1535
+ isFirstPage: true,
1536
+ isLastPage: true,
1537
+ },
1538
+ ],
1539
+ },
1540
+ ],
1541
+ });
1542
+
1543
+ expect(clearedScopes).toEqual([{ project_id: 'p1' }]);
1544
+ });
1051
1545
  });