@syncular/client 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 (83) hide show
  1. package/README.md +23 -0
  2. package/dist/blobs/index.js +3 -3
  3. package/dist/client.d.ts +10 -5
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +70 -21
  6. package/dist/client.js.map +1 -1
  7. package/dist/conflicts.d.ts.map +1 -1
  8. package/dist/conflicts.js +1 -7
  9. package/dist/conflicts.js.map +1 -1
  10. package/dist/create-client.d.ts +5 -1
  11. package/dist/create-client.d.ts.map +1 -1
  12. package/dist/create-client.js +22 -10
  13. package/dist/create-client.js.map +1 -1
  14. package/dist/engine/SyncEngine.d.ts +24 -2
  15. package/dist/engine/SyncEngine.d.ts.map +1 -1
  16. package/dist/engine/SyncEngine.js +290 -43
  17. package/dist/engine/SyncEngine.js.map +1 -1
  18. package/dist/engine/index.js +2 -2
  19. package/dist/engine/types.d.ts +16 -4
  20. package/dist/engine/types.d.ts.map +1 -1
  21. package/dist/handlers/create-handler.d.ts +15 -5
  22. package/dist/handlers/create-handler.d.ts.map +1 -1
  23. package/dist/handlers/create-handler.js +35 -24
  24. package/dist/handlers/create-handler.js.map +1 -1
  25. package/dist/handlers/types.d.ts +5 -5
  26. package/dist/handlers/types.d.ts.map +1 -1
  27. package/dist/index.js +19 -19
  28. package/dist/migrate.d.ts +1 -1
  29. package/dist/migrate.d.ts.map +1 -1
  30. package/dist/migrate.js +148 -28
  31. package/dist/migrate.js.map +1 -1
  32. package/dist/mutations.d.ts +3 -1
  33. package/dist/mutations.d.ts.map +1 -1
  34. package/dist/mutations.js +93 -18
  35. package/dist/mutations.js.map +1 -1
  36. package/dist/outbox.d.ts.map +1 -1
  37. package/dist/outbox.js +1 -11
  38. package/dist/outbox.js.map +1 -1
  39. package/dist/plugins/incrementing-version.d.ts +1 -1
  40. package/dist/plugins/incrementing-version.js +2 -2
  41. package/dist/plugins/index.js +2 -2
  42. package/dist/proxy/dialect.js +1 -1
  43. package/dist/proxy/driver.js +1 -1
  44. package/dist/proxy/index.js +4 -4
  45. package/dist/proxy/mutations.js +1 -1
  46. package/dist/pull-engine.d.ts +29 -3
  47. package/dist/pull-engine.d.ts.map +1 -1
  48. package/dist/pull-engine.js +314 -78
  49. package/dist/pull-engine.js.map +1 -1
  50. package/dist/push-engine.d.ts.map +1 -1
  51. package/dist/push-engine.js +28 -3
  52. package/dist/push-engine.js.map +1 -1
  53. package/dist/query/QueryContext.js +1 -1
  54. package/dist/query/index.js +3 -3
  55. package/dist/query/tracked-select.d.ts +2 -1
  56. package/dist/query/tracked-select.d.ts.map +1 -1
  57. package/dist/query/tracked-select.js +1 -1
  58. package/dist/schema.d.ts +2 -2
  59. package/dist/schema.d.ts.map +1 -1
  60. package/dist/sync-loop.d.ts +5 -1
  61. package/dist/sync-loop.d.ts.map +1 -1
  62. package/dist/sync-loop.js +167 -18
  63. package/dist/sync-loop.js.map +1 -1
  64. package/package.json +30 -6
  65. package/src/client.test.ts +369 -0
  66. package/src/client.ts +101 -22
  67. package/src/conflicts.ts +1 -10
  68. package/src/create-client.ts +33 -5
  69. package/src/engine/SyncEngine.test.ts +157 -0
  70. package/src/engine/SyncEngine.ts +359 -40
  71. package/src/engine/types.ts +22 -4
  72. package/src/handlers/create-handler.ts +86 -37
  73. package/src/handlers/types.ts +10 -4
  74. package/src/migrate.ts +215 -33
  75. package/src/mutations.ts +143 -21
  76. package/src/outbox.ts +1 -15
  77. package/src/plugins/incrementing-version.ts +2 -2
  78. package/src/pull-engine.test.ts +147 -0
  79. package/src/pull-engine.ts +392 -77
  80. package/src/push-engine.ts +33 -1
  81. package/src/query/tracked-select.ts +1 -1
  82. package/src/schema.ts +2 -2
  83. package/src/sync-loop.ts +215 -19
package/src/mutations.ts CHANGED
@@ -14,11 +14,19 @@
14
14
  */
15
15
 
16
16
  import type {
17
+ ColumnCodecDialect,
18
+ ColumnCodecSource,
17
19
  SyncOperation,
18
20
  SyncPushRequest,
19
21
  SyncPushResponse,
20
22
  SyncTransport,
21
23
  } from '@syncular/core';
24
+ import {
25
+ applyCodecsToDbRow,
26
+ isRecord,
27
+ randomId,
28
+ toTableColumnCodecs,
29
+ } from '@syncular/core';
22
30
  import type { Insertable, Kysely, Transaction, Updateable } from 'kysely';
23
31
  import { sql } from 'kysely';
24
32
  import { enqueueOutboxCommit } from './outbox';
@@ -138,21 +146,6 @@ export type MutationsApi<DB, CommitOptions = unknown> = {
138
146
  [T in KnownTableKey<DB>]: TableMutations<DB, T>;
139
147
  };
140
148
 
141
- function randomId(): string {
142
- if (
143
- typeof crypto !== 'undefined' &&
144
- typeof crypto.randomUUID === 'function'
145
- ) {
146
- return crypto.randomUUID();
147
- }
148
- // Very small fallback; good enough for tests.
149
- return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
150
- }
151
-
152
- function isRecord(value: unknown): value is Record<string, unknown> {
153
- return typeof value === 'object' && value !== null && !Array.isArray(value);
154
- }
155
-
156
149
  function sanitizePayload(
157
150
  payload: Record<string, unknown>,
158
151
  args: { omit: string[] }
@@ -268,6 +261,32 @@ async function dynamicInsert<T>(
268
261
  `.execute(trx);
269
262
  }
270
263
 
264
+ async function dynamicUpsert<T>(
265
+ trx: Transaction<T>,
266
+ table: string,
267
+ idColumn: string,
268
+ id: string,
269
+ values: Record<string, unknown>
270
+ ): Promise<void> {
271
+ validateTableName(table);
272
+ validateColumnName(idColumn);
273
+
274
+ // Check if the row already exists
275
+ const existing = await sql`
276
+ select 1 from ${sql.table(table)}
277
+ where ${sql.ref(idColumn)} = ${sql.val(id)}
278
+ limit 1
279
+ `.execute(trx);
280
+
281
+ if (existing.rows.length > 0) {
282
+ // Row exists: just update the provided columns
283
+ await dynamicUpdate(trx, table, idColumn, id, values);
284
+ } else {
285
+ // Row doesn't exist: insert with all provided columns + id
286
+ await dynamicInsert(trx, table, { ...values, [idColumn]: id });
287
+ }
288
+ }
289
+
271
290
  async function dynamicUpdate<T>(
272
291
  trx: Transaction<T>,
273
292
  table: string,
@@ -381,6 +400,8 @@ export interface OutboxCommitConfig<DB extends SyncClientDb> {
381
400
  idColumn?: string;
382
401
  versionColumn?: string | null;
383
402
  omitColumns?: string[];
403
+ columnCodecs?: ColumnCodecSource;
404
+ codecDialect?: ColumnCodecDialect;
384
405
  }
385
406
 
386
407
  export function createOutboxCommit<DB extends SyncClientDb>(
@@ -389,6 +410,7 @@ export function createOutboxCommit<DB extends SyncClientDb>(
389
410
  const idColumn = config.idColumn ?? 'id';
390
411
  const versionColumn = config.versionColumn ?? 'server_version';
391
412
  const omitColumns = config.omitColumns ?? [];
413
+ const codecDialect = config.codecDialect ?? 'sqlite';
392
414
 
393
415
  return async (fn) => {
394
416
  const operations: SyncOperation[] = [];
@@ -402,6 +424,38 @@ export function createOutboxCommit<DB extends SyncClientDb>(
402
424
  .transaction()
403
425
  .execute(async (trx) => {
404
426
  const txTableCache = new Map<string, any>();
427
+ const tableCodecCache = new Map<
428
+ string,
429
+ Map<string, ReturnType<typeof toTableColumnCodecs>>
430
+ >();
431
+ const resolveTableCodecs = (
432
+ table: string,
433
+ row: Record<string, unknown>
434
+ ) => {
435
+ const columnCodecs = config.columnCodecs;
436
+ if (!columnCodecs) return {};
437
+ const columns = Object.keys(row);
438
+ if (columns.length === 0) return {};
439
+
440
+ let tableCache = tableCodecCache.get(table);
441
+ if (!tableCache) {
442
+ tableCache = new Map<
443
+ string,
444
+ ReturnType<typeof toTableColumnCodecs>
445
+ >();
446
+ tableCodecCache.set(table, tableCache);
447
+ }
448
+
449
+ const cacheKey = columns.slice().sort().join('\u0000');
450
+ const cached = tableCache.get(cacheKey);
451
+ if (cached) return cached;
452
+
453
+ const resolved = toTableColumnCodecs(table, columnCodecs, columns, {
454
+ dialect: codecDialect,
455
+ });
456
+ tableCache.set(cacheKey, resolved);
457
+ return resolved;
458
+ };
405
459
 
406
460
  const makeTxTable = (table: string) => {
407
461
  const cached = txTableCache.get(table);
@@ -415,8 +469,13 @@ export function createOutboxCommit<DB extends SyncClientDb>(
415
469
  typeof rawId === 'string' && rawId ? rawId : randomId();
416
470
 
417
471
  const row = { ...raw, [idColumn]: id };
472
+ const dbRow = applyCodecsToDbRow(
473
+ row,
474
+ resolveTableCodecs(table, row),
475
+ codecDialect
476
+ );
418
477
 
419
- await dynamicInsert(trx, table, row);
478
+ await dynamicInsert(trx, table, dbRow);
420
479
 
421
480
  const payload = sanitizePayload(row, {
422
481
  omit: [
@@ -451,8 +510,16 @@ export function createOutboxCommit<DB extends SyncClientDb>(
451
510
  toInsert.push({ ...raw, [idColumn]: id });
452
511
  }
453
512
 
454
- if (toInsert.length > 0) {
455
- await dynamicInsert(trx, table, toInsert);
513
+ const dbRows = toInsert.map((row) =>
514
+ applyCodecsToDbRow(
515
+ row,
516
+ resolveTableCodecs(table, row),
517
+ codecDialect
518
+ )
519
+ );
520
+
521
+ if (dbRows.length > 0) {
522
+ await dynamicInsert(trx, table, dbRows);
456
523
  }
457
524
 
458
525
  for (let i = 0; i < toInsert.length; i++) {
@@ -492,8 +559,13 @@ export function createOutboxCommit<DB extends SyncClientDb>(
492
559
 
493
560
  const hasExplicitBaseVersion =
494
561
  !!opts && hasOwn(opts, 'baseVersion');
562
+ const dbPatch = applyCodecsToDbRow(
563
+ sanitized,
564
+ resolveTableCodecs(table, sanitized),
565
+ codecDialect
566
+ );
495
567
 
496
- await dynamicUpdate(trx, table, idColumn, id, sanitized);
568
+ await dynamicUpdate(trx, table, idColumn, id, dbPatch);
497
569
 
498
570
  const baseVersion = hasExplicitBaseVersion
499
571
  ? (opts!.baseVersion ?? null)
@@ -547,7 +619,46 @@ export function createOutboxCommit<DB extends SyncClientDb>(
547
619
  },
548
620
 
549
621
  async upsert(id, patch, opts) {
550
- await tableApi.update(id, patch, opts);
622
+ const rawPatch = isRecord(patch) ? patch : {};
623
+ const sanitized = sanitizePayload(rawPatch, {
624
+ omit: [
625
+ idColumn,
626
+ ...(versionColumn ? [versionColumn] : []),
627
+ ...omitColumns,
628
+ ],
629
+ });
630
+
631
+ const hasExplicitBaseVersion =
632
+ !!opts && hasOwn(opts, 'baseVersion');
633
+ const dbPatch = applyCodecsToDbRow(
634
+ sanitized,
635
+ resolveTableCodecs(table, sanitized),
636
+ codecDialect
637
+ );
638
+
639
+ await dynamicUpsert(trx, table, idColumn, id, dbPatch);
640
+
641
+ const baseVersion = hasExplicitBaseVersion
642
+ ? (opts!.baseVersion ?? null)
643
+ : versionColumn
644
+ ? await readBaseVersion({
645
+ trx: trx,
646
+ table,
647
+ rowId: id,
648
+ idColumn,
649
+ versionColumn,
650
+ })
651
+ : null;
652
+
653
+ operations.push({
654
+ table: table,
655
+ row_id: id,
656
+ op: 'upsert',
657
+ payload: sanitized,
658
+ base_version: coerceBaseVersion(baseVersion),
659
+ });
660
+
661
+ localMutations.push({ table, rowId: id, op: 'upsert' });
551
662
  },
552
663
  };
553
664
 
@@ -814,7 +925,18 @@ export function createPushCommit<DB = AnyDb>(
814
925
  }
815
926
  }
816
927
 
817
- const rawResponse = await config.transport.push(requestToSend);
928
+ const combined = await config.transport.sync({
929
+ clientId: requestToSend.clientId,
930
+ push: {
931
+ clientCommitId: requestToSend.clientCommitId,
932
+ operations: requestToSend.operations,
933
+ schemaVersion: requestToSend.schemaVersion,
934
+ },
935
+ });
936
+ if (!combined.push) {
937
+ throw new Error('Server returned no push response');
938
+ }
939
+ const rawResponse = combined.push;
818
940
 
819
941
  let response = rawResponse;
820
942
  if (sortedPlugins.length > 0) {
package/src/outbox.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { SyncOperation } from '@syncular/core';
6
+ import { isRecord, randomId } from '@syncular/core';
6
7
  import type { Kysely } from 'kysely';
7
8
  import { sql } from 'kysely';
8
9
  import type { OutboxCommitStatus, SyncClientDb } from './schema';
@@ -22,21 +23,6 @@ export interface OutboxCommit {
22
23
  schema_version: number;
23
24
  }
24
25
 
25
- function randomId(): string {
26
- if (
27
- typeof crypto !== 'undefined' &&
28
- typeof crypto.randomUUID === 'function'
29
- ) {
30
- return crypto.randomUUID();
31
- }
32
- // Very small fallback; good enough for tests.
33
- return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
34
- }
35
-
36
- function isRecord(value: unknown): value is Record<string, unknown> {
37
- return typeof value === 'object' && value !== null && !Array.isArray(value);
38
- }
39
-
40
26
  function isSyncOperation(value: unknown): value is SyncOperation {
41
27
  if (!isRecord(value)) return false;
42
28
  if (typeof value.table !== 'string') return false;
@@ -50,7 +50,7 @@ function touchLru(
50
50
  * - Operations are pushed in commit order for a given client (outbox ordering).
51
51
  *
52
52
  * This plugin:
53
- * - Tracks the "next expected server version" per (shape, table, row_id) based on
53
+ * - Tracks the "next expected server version" per (table, row_id) based on
54
54
  * successfully applied pushes.
55
55
  * - Rewrites outgoing `base_version` to that expected version when it is higher
56
56
  * than the caller-provided value, preventing "self-conflicts" on hot rows.
@@ -122,7 +122,7 @@ export function createIncrementingVersionPlugin(
122
122
  maxTrackedRows
123
123
  );
124
124
  } else {
125
- // Insert case: most shapes start at version 1.
125
+ // Insert case: most tables start at version 1.
126
126
  touchLru(nextExpectedBaseVersionByRow, key, 1, maxTrackedRows);
127
127
  }
128
128
  }
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { gzipSync } from 'node:zlib';
3
+ import {
4
+ encodeSnapshotRows,
5
+ type SyncPullResponse,
6
+ type SyncTransport,
7
+ } from '@syncular/core';
8
+ import { type Kysely, sql } from 'kysely';
9
+ import { createBunSqliteDb } from '../../dialect-bun-sqlite/src';
10
+ import { createClientHandler } from './handlers/create-handler';
11
+ import { ClientTableRegistry } from './handlers/registry';
12
+ import { ensureClientSyncSchema } from './migrate';
13
+ import { applyPullResponse, buildPullRequest } from './pull-engine';
14
+ import type { SyncClientDb } from './schema';
15
+
16
+ interface ItemsTable {
17
+ id: string;
18
+ name: string;
19
+ }
20
+
21
+ interface TestDb extends SyncClientDb {
22
+ items: ItemsTable;
23
+ }
24
+
25
+ function createStreamFromBytes(
26
+ bytes: Uint8Array,
27
+ chunkSize = 1024
28
+ ): ReadableStream<Uint8Array> {
29
+ return new ReadableStream<Uint8Array>({
30
+ start(controller) {
31
+ for (let index = 0; index < bytes.length; index += chunkSize) {
32
+ controller.enqueue(bytes.subarray(index, index + chunkSize));
33
+ }
34
+ controller.close();
35
+ },
36
+ });
37
+ }
38
+
39
+ describe('applyPullResponse chunk streaming', () => {
40
+ let db: Kysely<TestDb>;
41
+
42
+ beforeEach(async () => {
43
+ db = createBunSqliteDb<TestDb>({ path: ':memory:' });
44
+ await ensureClientSyncSchema(db);
45
+ await db.schema
46
+ .createTable('items')
47
+ .addColumn('id', 'text', (col) => col.primaryKey())
48
+ .addColumn('name', 'text', (col) => col.notNull())
49
+ .execute();
50
+ });
51
+
52
+ afterEach(async () => {
53
+ await db.destroy();
54
+ });
55
+
56
+ it('applies chunked bootstrap snapshots using streaming transport', async () => {
57
+ const rows = Array.from({ length: 5000 }, (_, index) => ({
58
+ id: `${index + 1}`,
59
+ name: `Item ${index + 1}`,
60
+ }));
61
+ const encoded = encodeSnapshotRows(rows);
62
+ const compressed = new Uint8Array(gzipSync(encoded));
63
+
64
+ let streamFetchCount = 0;
65
+ const transport: SyncTransport = {
66
+ async sync() {
67
+ return {};
68
+ },
69
+ async fetchSnapshotChunk() {
70
+ throw new Error('fetchSnapshotChunk should not be used');
71
+ },
72
+ async fetchSnapshotChunkStream() {
73
+ streamFetchCount += 1;
74
+ return createStreamFromBytes(compressed, 257);
75
+ },
76
+ };
77
+
78
+ const handlers = new ClientTableRegistry<TestDb>().register(
79
+ createClientHandler({
80
+ table: 'items',
81
+ scopes: ['items:{id}'],
82
+ })
83
+ );
84
+
85
+ const options = {
86
+ clientId: 'client-1',
87
+ subscriptions: [
88
+ {
89
+ id: 'items-sub',
90
+ table: 'items',
91
+ scopes: {},
92
+ },
93
+ ],
94
+ stateId: 'default',
95
+ };
96
+
97
+ const pullState = await buildPullRequest(db, options);
98
+
99
+ const response: SyncPullResponse = {
100
+ ok: true,
101
+ subscriptions: [
102
+ {
103
+ id: 'items-sub',
104
+ status: 'active',
105
+ scopes: {},
106
+ bootstrap: true,
107
+ bootstrapState: null,
108
+ nextCursor: 1,
109
+ commits: [],
110
+ snapshots: [
111
+ {
112
+ table: 'items',
113
+ rows: [],
114
+ chunks: [
115
+ {
116
+ id: 'chunk-1',
117
+ byteLength: compressed.length,
118
+ sha256: '',
119
+ encoding: 'json-row-frame-v1',
120
+ compression: 'gzip',
121
+ },
122
+ ],
123
+ isFirstPage: true,
124
+ isLastPage: true,
125
+ },
126
+ ],
127
+ },
128
+ ],
129
+ };
130
+
131
+ await applyPullResponse(
132
+ db,
133
+ transport,
134
+ handlers,
135
+ options,
136
+ pullState,
137
+ response
138
+ );
139
+
140
+ const countResult = await sql<{ count: number }>`
141
+ select count(*) as count
142
+ from ${sql.table('items')}
143
+ `.execute(db);
144
+ expect(Number(countResult.rows[0]?.count ?? 0)).toBe(rows.length);
145
+ expect(streamFetchCount).toBe(1);
146
+ });
147
+ });