@syncular/client 0.0.5-42 → 0.0.6-101

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 (70) hide show
  1. package/dist/client.d.ts +3 -3
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +7 -1
  4. package/dist/client.js.map +1 -1
  5. package/dist/create-client.d.ts +3 -4
  6. package/dist/create-client.d.ts.map +1 -1
  7. package/dist/create-client.js +16 -12
  8. package/dist/create-client.js.map +1 -1
  9. package/dist/engine/SyncEngine.d.ts.map +1 -1
  10. package/dist/engine/SyncEngine.js +49 -29
  11. package/dist/engine/SyncEngine.js.map +1 -1
  12. package/dist/engine/types.d.ts +3 -3
  13. package/dist/engine/types.d.ts.map +1 -1
  14. package/dist/handlers/collection.d.ts +6 -0
  15. package/dist/handlers/collection.d.ts.map +1 -0
  16. package/dist/handlers/collection.js +21 -0
  17. package/dist/handlers/collection.js.map +1 -0
  18. package/dist/handlers/create-handler.d.ts +1 -1
  19. package/dist/handlers/create-handler.d.ts.map +1 -1
  20. package/dist/handlers/create-handler.js +3 -3
  21. package/dist/handlers/create-handler.js.map +1 -1
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/migrate.d.ts.map +1 -1
  27. package/dist/migrate.js +12 -0
  28. package/dist/migrate.js.map +1 -1
  29. package/dist/mutations.d.ts +1 -1
  30. package/dist/mutations.d.ts.map +1 -1
  31. package/dist/mutations.js +3 -3
  32. package/dist/mutations.js.map +1 -1
  33. package/dist/pull-engine.d.ts +11 -14
  34. package/dist/pull-engine.d.ts.map +1 -1
  35. package/dist/pull-engine.js +68 -6
  36. package/dist/pull-engine.js.map +1 -1
  37. package/dist/push-engine.d.ts.map +1 -1
  38. package/dist/push-engine.js +12 -0
  39. package/dist/push-engine.js.map +1 -1
  40. package/dist/sync-loop.d.ts +2 -2
  41. package/dist/sync-loop.d.ts.map +1 -1
  42. package/dist/sync-loop.js +5 -2
  43. package/dist/sync-loop.js.map +1 -1
  44. package/dist/sync.d.ts +32 -0
  45. package/dist/sync.d.ts.map +1 -0
  46. package/dist/sync.js +55 -0
  47. package/dist/sync.js.map +1 -0
  48. package/package.json +4 -4
  49. package/src/client.test.ts +18 -9
  50. package/src/client.ts +11 -4
  51. package/src/create-client.test.ts +83 -0
  52. package/src/create-client.ts +21 -16
  53. package/src/engine/SyncEngine.test.ts +241 -32
  54. package/src/engine/SyncEngine.ts +53 -33
  55. package/src/engine/types.ts +3 -3
  56. package/src/handlers/collection.ts +36 -0
  57. package/src/handlers/create-handler.ts +4 -4
  58. package/src/index.ts +2 -1
  59. package/src/migrate.ts +14 -0
  60. package/src/mutations.ts +4 -4
  61. package/src/pull-engine.test.ts +151 -6
  62. package/src/pull-engine.ts +93 -21
  63. package/src/push-engine.ts +15 -0
  64. package/src/sync-loop.ts +13 -5
  65. package/src/sync.ts +170 -0
  66. package/dist/handlers/registry.d.ts +0 -15
  67. package/dist/handlers/registry.d.ts.map +0 -1
  68. package/dist/handlers/registry.js +0 -29
  69. package/dist/handlers/registry.js.map +0 -1
  70. package/src/handlers/registry.ts +0 -36
package/dist/sync.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { ColumnCodecDialect, ColumnCodecSource, ScopeDefinition, ScopeValuesFromPatterns, SyncSubscriptionRequest } from '@syncular/core';
2
+ import { type CreateClientHandlerOptions } from './handlers/create-handler';
3
+ import type { ClientTableHandler } from './handlers/types';
4
+ import type { SyncClientDb } from './schema';
5
+ type ClientSyncSubscription<ScopeDefs extends readonly ScopeDefinition[]> = Omit<SyncSubscriptionRequest, 'cursor' | 'table' | 'scopes'> & {
6
+ table: string;
7
+ scopes?: ScopeValuesFromPatterns<ScopeDefs>;
8
+ };
9
+ type SharedTableName<DB extends SyncClientDb> = keyof DB & string;
10
+ export type ClientSyncHandlerOptionsForTable<DB extends SyncClientDb, TableName extends SharedTableName<DB>, ScopeDefs extends readonly ScopeDefinition[], Identity> = Omit<CreateClientHandlerOptions<DB, TableName, ScopeDefs>, 'codecs' | 'codecDialect' | 'subscribe'> & {
11
+ codecs?: ColumnCodecSource;
12
+ codecDialect?: ColumnCodecDialect;
13
+ subscribe?: ClientSyncSubscription<ScopeDefs> | ClientSyncSubscription<ScopeDefs>[] | null | ((args: {
14
+ identity: Identity;
15
+ }) => ClientSyncSubscription<ScopeDefs> | ClientSyncSubscription<ScopeDefs>[] | null);
16
+ };
17
+ export interface ClientSyncConfig<DB extends SyncClientDb = SyncClientDb, Identity = {
18
+ actorId: string;
19
+ }> {
20
+ handlers: ClientTableHandler<DB>[];
21
+ subscriptions(identity: Identity): Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
22
+ }
23
+ export interface DefineClientSyncOptions {
24
+ codecs?: ColumnCodecSource;
25
+ codecDialect?: ColumnCodecDialect;
26
+ }
27
+ export interface ClientSyncBuilder<DB extends SyncClientDb, ScopeDefs extends readonly ScopeDefinition[], Identity> extends ClientSyncConfig<DB, Identity> {
28
+ addHandler<TableName extends SharedTableName<DB>>(options: ClientSyncHandlerOptionsForTable<DB, TableName, ScopeDefs, Identity>): this;
29
+ }
30
+ export declare function defineClientSync<DB extends SyncClientDb, ScopeDefs extends readonly ScopeDefinition[], Identity>(options: DefineClientSyncOptions): ClientSyncBuilder<DB, ScopeDefs, Identity>;
31
+ export {};
32
+ //# sourceMappingURL=sync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAEf,uBAAuB,EACvB,uBAAuB,EACxB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,KAAK,0BAA0B,EAEhC,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAE7C,KAAK,sBAAsB,CAAC,SAAS,SAAS,SAAS,eAAe,EAAE,IACtE,IAAI,CAAC,uBAAuB,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAC,GAAG;IAC7D,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,uBAAuB,CAAC,SAAS,CAAC,CAAC;CAC7C,CAAC;AAEJ,KAAK,eAAe,CAAC,EAAE,SAAS,YAAY,IAAI,MAAM,EAAE,GAAG,MAAM,CAAC;AAElE,MAAM,MAAM,gCAAgC,CAC1C,EAAE,SAAS,YAAY,EACvB,SAAS,SAAS,eAAe,CAAC,EAAE,CAAC,EACrC,SAAS,SAAS,SAAS,eAAe,EAAE,EAC5C,QAAQ,IACN,IAAI,CACN,0BAA0B,CAAC,EAAE,EAAE,SAAS,EAAE,SAAS,CAAC,EACpD,QAAQ,GAAG,cAAc,GAAG,WAAW,CACxC,GAAG;IACF,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,YAAY,CAAC,EAAE,kBAAkB,CAAC;IAClC,SAAS,CAAC,EACN,sBAAsB,CAAC,SAAS,CAAC,GACjC,sBAAsB,CAAC,SAAS,CAAC,EAAE,GACnC,IAAI,GACJ,CAAC,CAAC,IAAI,EAAE;QACN,QAAQ,EAAE,QAAQ,CAAC;KACpB,KACG,sBAAsB,CAAC,SAAS,CAAC,GACjC,sBAAsB,CAAC,SAAS,CAAC,EAAE,GACnC,IAAI,CAAC,CAAC;CACf,CAAC;AAEF,MAAM,WAAW,gBAAgB,CAC/B,EAAE,SAAS,YAAY,GAAG,YAAY,EACtC,QAAQ,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE;IAE9B,QAAQ,EAAE,kBAAkB,CAAC,EAAE,CAAC,EAAE,CAAC;IACnC,aAAa,CACX,QAAQ,EAAE,QAAQ,GACjB,KAAK,CAAC,IAAI,CAAC,uBAAuB,EAAE,QAAQ,CAAC,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,YAAY,CAAC,EAAE,kBAAkB,CAAC;CACnC;AAED,MAAM,WAAW,iBAAiB,CAChC,EAAE,SAAS,YAAY,EACvB,SAAS,SAAS,SAAS,eAAe,EAAE,EAC5C,QAAQ,CACR,SAAQ,gBAAgB,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtC,UAAU,CAAC,SAAS,SAAS,eAAe,CAAC,EAAE,CAAC,EAC9C,OAAO,EAAE,gCAAgC,CACvC,EAAE,EACF,SAAS,EACT,SAAS,EACT,QAAQ,CACT,GACA,IAAI,CAAC;CACT;AAED,wBAAgB,gBAAgB,CAC9B,EAAE,SAAS,YAAY,EACvB,SAAS,SAAS,SAAS,eAAe,EAAE,EAC5C,QAAQ,EAER,OAAO,EAAE,uBAAuB,GAC/B,iBAAiB,CAAC,EAAE,EAAE,SAAS,EAAE,QAAQ,CAAC,CAuF5C"}
package/dist/sync.js ADDED
@@ -0,0 +1,55 @@
1
+ import { createClientHandler, } from './handlers/create-handler.js';
2
+ export function defineClientSync(options) {
3
+ const handlers = [];
4
+ const registeredTables = new Set();
5
+ const subscriptionsByTable = new Map();
6
+ const toScopeValues = (value) => {
7
+ const result = {};
8
+ for (const [key, scopeValue] of Object.entries((value ?? {}))) {
9
+ if (scopeValue === undefined)
10
+ continue;
11
+ result[key] = scopeValue;
12
+ }
13
+ return result;
14
+ };
15
+ const sync = {
16
+ handlers,
17
+ addHandler(handlerOptions) {
18
+ if (registeredTables.has(handlerOptions.table)) {
19
+ throw new Error(`Client table handler already registered: ${handlerOptions.table}`);
20
+ }
21
+ handlers.push(createClientHandler({
22
+ ...handlerOptions,
23
+ subscribe: false,
24
+ codecs: options.codecs,
25
+ codecDialect: options.codecDialect,
26
+ }));
27
+ subscriptionsByTable.set(handlerOptions.table, handlerOptions.subscribe);
28
+ registeredTables.add(handlerOptions.table);
29
+ return sync;
30
+ },
31
+ subscriptions(identity) {
32
+ const resolved = [];
33
+ for (const [table, subscribe] of subscriptionsByTable.entries()) {
34
+ if (!subscribe)
35
+ continue;
36
+ const value = typeof subscribe === 'function' ? subscribe({ identity }) : subscribe;
37
+ if (!value)
38
+ continue;
39
+ const entries = Array.isArray(value) ? value : [value];
40
+ for (const entry of entries) {
41
+ resolved.push({
42
+ id: entry.id,
43
+ table: entry.table ?? table,
44
+ scopes: toScopeValues(entry.scopes),
45
+ params: entry.params,
46
+ bootstrapState: entry.bootstrapState,
47
+ });
48
+ }
49
+ }
50
+ return resolved;
51
+ },
52
+ };
53
+ return sync;
54
+ }
55
+ //# sourceMappingURL=sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync.js","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"AAQA,OAAO,EAEL,mBAAmB,GACpB,MAAM,2BAA2B,CAAC;AAiEnC,MAAM,UAAU,gBAAgB,CAK9B,OAAgC,EACY;IAC5C,MAAM,QAAQ,GAA6B,EAAE,CAAC;IAC9C,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAU,CAAC;IAC3C,MAAM,oBAAoB,GAAG,IAAI,GAAG,EAQjC,CAAC;IAEJ,MAAM,aAAa,GAAG,CACpB,KAAqD,EACzB,EAAE,CAAC;QAC/B,MAAM,MAAM,GAA+B,EAAE,CAAC;QAC9C,KAAK,MAAM,CAAC,GAAG,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAC5C,CAAC,KAAK,IAAI,EAAE,CAA2C,CACxD,EAAE,CAAC;YACF,IAAI,UAAU,KAAK,SAAS;gBAAE,SAAS;YACvC,MAAM,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC;QAC3B,CAAC;QACD,OAAO,MAAM,CAAC;IAAA,CACf,CAAC;IAEF,MAAM,IAAI,GAA+C;QACvD,QAAQ;QACR,UAAU,CACR,cAKC,EACD;YACA,IAAI,gBAAgB,CAAC,GAAG,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,CAAC;gBAC/C,MAAM,IAAI,KAAK,CACb,4CAA4C,cAAc,CAAC,KAAK,EAAE,CACnE,CAAC;YACJ,CAAC;YAED,QAAQ,CAAC,IAAI,CACX,mBAAmB,CAAC;gBAClB,GAAG,cAAc;gBACjB,SAAS,EAAE,KAAK;gBAChB,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,YAAY,EAAE,OAAO,CAAC,YAAY;aACnC,CAAC,CACH,CAAC;YACF,oBAAoB,CAAC,GAAG,CACtB,cAAc,CAAC,KAAK,EACpB,cAAc,CAAC,SAKD,CACf,CAAC;YACF,gBAAgB,CAAC,GAAG,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;YAC3C,OAAO,IAAI,CAAC;QAAA,CACb;QACD,aAAa,CACX,QAAkB,EAC8B;YAChD,MAAM,QAAQ,GAAmD,EAAE,CAAC;YACpE,KAAK,MAAM,CAAC,KAAK,EAAE,SAAS,CAAC,IAAI,oBAAoB,CAAC,OAAO,EAAE,EAAE,CAAC;gBAChE,IAAI,CAAC,SAAS;oBAAE,SAAS;gBACzB,MAAM,KAAK,GACT,OAAO,SAAS,KAAK,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;gBACxE,IAAI,CAAC,KAAK;oBAAE,SAAS;gBACrB,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;gBACvD,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;oBAC5B,QAAQ,CAAC,IAAI,CAAC;wBACZ,EAAE,EAAE,KAAK,CAAC,EAAE;wBACZ,KAAK,EAAE,KAAK,CAAC,KAAK,IAAI,KAAK;wBAC3B,MAAM,EAAE,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC;wBACnC,MAAM,EAAE,KAAK,CAAC,MAAM;wBACpB,cAAc,EAAE,KAAK,CAAC,cAAc;qBACrC,CAAC,CAAC;gBACL,CAAC;YACH,CAAC;YACD,OAAO,QAAQ,CAAC;QAAA,CACjB;KACF,CAAC;IAEF,OAAO,IAAI,CAAC;AAAA,CACb"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.0.5-42",
3
+ "version": "0.0.6-101",
4
4
  "description": "Client-side sync engine with offline-first support, outbox, and conflict resolution",
5
- "license": "MIT",
5
+ "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
7
7
  "homepage": "https://syncular.dev",
8
8
  "repository": {
@@ -46,8 +46,8 @@
46
46
  "release": "bunx syncular-publish"
47
47
  },
48
48
  "dependencies": {
49
- "@syncular/core": "0.0.5-42",
50
- "@syncular/transport-http": "0.0.5-42"
49
+ "@syncular/core": "0.0.6-101",
50
+ "@syncular/transport-http": "0.0.6-101"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "kysely": "*"
@@ -1,12 +1,12 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
- import type { SyncTransport } from '@syncular/core';
2
+ import { createDatabase, type SyncTransport } from '@syncular/core';
3
3
  import type { Kysely } from 'kysely';
4
4
  import { sql } from 'kysely';
5
- import { createBunSqliteDb } from '../../dialect-bun-sqlite/src';
5
+ import { createBunSqliteDialect } from '../../dialect-bun-sqlite/src';
6
6
  import { ensureClientBlobSchema } from './blobs/migrate';
7
7
  import { Client, type ClientBlobStorage } from './client';
8
8
  import { SyncEngine } from './engine/SyncEngine';
9
- import { ClientTableRegistry } from './handlers/registry';
9
+ import type { ClientHandlerCollection } from './handlers/collection';
10
10
  import { ensureClientSyncSchema } from './migrate';
11
11
  import type { SyncClientDb } from './schema';
12
12
 
@@ -151,7 +151,10 @@ describe('Client conflict events', () => {
151
151
  }
152
152
 
153
153
  beforeEach(async () => {
154
- db = createBunSqliteDb<TestDb>({ path: ':memory:' });
154
+ db = createDatabase<TestDb>({
155
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
156
+ family: 'sqlite',
157
+ });
155
158
  await ensureClientSyncSchema(db);
156
159
  await db.schema
157
160
  .createTable('tasks')
@@ -162,7 +165,7 @@ describe('Client conflict events', () => {
162
165
  )
163
166
  .execute();
164
167
 
165
- const handlers = new ClientTableRegistry<TestDb>();
168
+ const handlers: ClientHandlerCollection<TestDb> = [];
166
169
  client = new Client<TestDb>({
167
170
  db,
168
171
  transport: noopTransport,
@@ -270,12 +273,15 @@ describe('Client blob upload queue recovery', () => {
270
273
  }
271
274
 
272
275
  beforeEach(async () => {
273
- db = createBunSqliteDb<TestDb>({ path: ':memory:' });
276
+ db = createDatabase<TestDb>({
277
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
278
+ family: 'sqlite',
279
+ });
274
280
  await ensureClientSyncSchema(db);
275
281
  await ensureClientBlobSchema(db);
276
282
  initiateCalls = 0;
277
283
 
278
- const handlers = new ClientTableRegistry<TestDb>();
284
+ const handlers: ClientHandlerCollection<TestDb> = [];
279
285
  const transport: SyncTransport = {
280
286
  ...noopTransport,
281
287
  blobs: {
@@ -373,10 +379,13 @@ describe('Client inspector snapshot', () => {
373
379
  let client: Client<TestDb>;
374
380
 
375
381
  beforeEach(async () => {
376
- db = createBunSqliteDb<TestDb>({ path: ':memory:' });
382
+ db = createDatabase<TestDb>({
383
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
384
+ family: 'sqlite',
385
+ });
377
386
  await ensureClientSyncSchema(db);
378
387
 
379
- const handlers = new ClientTableRegistry<TestDb>();
388
+ const handlers: ClientHandlerCollection<TestDb> = [];
380
389
  client = new Client<TestDb>({
381
390
  db,
382
391
  transport: noopTransport,
package/src/client.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  ColumnCodecSource,
16
16
  SyncTransport,
17
17
  } from '@syncular/core';
18
+ import { countSyncMetric } from '@syncular/core';
18
19
  import type { Kysely } from 'kysely';
19
20
  import { sql } from 'kysely';
20
21
  import { ensureClientBlobSchema } from './blobs/migrate';
@@ -37,7 +38,7 @@ import type {
37
38
  SyncResult,
38
39
  TransportHealth,
39
40
  } from './engine/types';
40
- import type { ClientTableRegistry } from './handlers/registry';
41
+ import type { ClientHandlerCollection } from './handlers/collection';
41
42
  import { ensureClientSyncSchema } from './migrate';
42
43
  import {
43
44
  createMutationsApi,
@@ -91,7 +92,7 @@ export interface ClientOptions<DB extends SyncClientDb> {
91
92
  transport: SyncTransport;
92
93
 
93
94
  /** Table handlers for applying snapshots and changes */
94
- tableHandlers: ClientTableRegistry<DB>;
95
+ tableHandlers: ClientHandlerCollection<DB>;
95
96
 
96
97
  /** Unique client identifier (e.g., device ID) */
97
98
  clientId: string;
@@ -132,7 +133,7 @@ export interface ClientOptions<DB extends SyncClientDb> {
132
133
  omitColumns?: string[];
133
134
 
134
135
  /** Optional: Column codec resolver */
135
- columnCodecs?: ColumnCodecSource;
136
+ codecs?: ColumnCodecSource;
136
137
 
137
138
  /** Optional: Codec dialect override (default: 'sqlite') */
138
139
  codecDialect?: ColumnCodecDialect;
@@ -341,7 +342,7 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
341
342
  idColumn: options.idColumn ?? 'id',
342
343
  versionColumn: options.versionColumn ?? 'server_version',
343
344
  omitColumns: options.omitColumns ?? [],
344
- columnCodecs: options.columnCodecs,
345
+ codecs: options.codecs,
345
346
  codecDialect: options.codecDialect,
346
347
  });
347
348
  this.mutations = createMutationsApi(commitFn) as MutationsApi<DB>;
@@ -714,6 +715,12 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
714
715
 
715
716
  await resolveConflict(this.options.db, { id, resolution: resolutionStr });
716
717
 
718
+ countSyncMetric('sync.conflicts.resolved', 1, {
719
+ attributes: {
720
+ strategy: resolution.strategy,
721
+ },
722
+ });
723
+
717
724
  this.emittedConflictIds.delete(id);
718
725
  if (resolvedConflict) {
719
726
  this.emit('conflict:resolved', resolvedConflict);
@@ -0,0 +1,83 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { createDatabase } from '@syncular/core';
3
+ import type { Kysely } from 'kysely';
4
+ import { createBunSqliteDialect } from '../../dialect-bun-sqlite/src';
5
+ import { createClient } from './create-client';
6
+ import type { SyncClientDb } from './schema';
7
+
8
+ interface TasksTable {
9
+ id: string;
10
+ user_id: string;
11
+ title: string;
12
+ server_version: number;
13
+ }
14
+
15
+ interface TestDb extends SyncClientDb {
16
+ tasks: TasksTable;
17
+ }
18
+
19
+ async function createTestDb(): Promise<Kysely<TestDb>> {
20
+ const db = createDatabase<TestDb>({
21
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
22
+ family: 'sqlite',
23
+ });
24
+ await db.schema
25
+ .createTable('tasks')
26
+ .addColumn('id', 'text', (col) => col.primaryKey())
27
+ .addColumn('user_id', 'text', (col) => col.notNull())
28
+ .addColumn('title', 'text', (col) => col.notNull())
29
+ .addColumn('server_version', 'integer', (col) => col.notNull().defaultTo(0))
30
+ .execute();
31
+ return db;
32
+ }
33
+
34
+ describe('createClient url normalization', () => {
35
+ const originalFetch = globalThis.fetch;
36
+
37
+ afterEach(() => {
38
+ globalThis.fetch = originalFetch;
39
+ });
40
+
41
+ it('accepts sync endpoint URLs without duplicating /sync', async () => {
42
+ const requests: string[] = [];
43
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
44
+ const request = input instanceof Request ? input : new Request(input);
45
+ requests.push(request.url);
46
+ return new Response(
47
+ JSON.stringify({
48
+ ok: true,
49
+ pull: { ok: true, subscriptions: [] },
50
+ }),
51
+ {
52
+ status: 200,
53
+ headers: { 'content-type': 'application/json' },
54
+ }
55
+ );
56
+ }) as typeof fetch;
57
+
58
+ const db = await createTestDb();
59
+ try {
60
+ const { destroy } = await createClient<TestDb>({
61
+ db,
62
+ actorId: 'user-1',
63
+ clientId: 'client-1',
64
+ url: 'http://localhost:4311/api/sync',
65
+ handlers: [
66
+ {
67
+ table: 'tasks',
68
+ subscribe: false,
69
+ async applySnapshot() {},
70
+ async clearAll() {},
71
+ async applyChange() {},
72
+ },
73
+ ],
74
+ });
75
+
76
+ destroy();
77
+ expect(requests).toContain('http://localhost:4311/api/sync');
78
+ expect(requests).not.toContain('http://localhost:4311/api/sync/sync');
79
+ } finally {
80
+ await db.destroy();
81
+ }
82
+ });
83
+ });
@@ -2,7 +2,7 @@
2
2
  * Simplified client factory
3
3
  *
4
4
  * Breaking changes from legacy Client:
5
- * - handlers: array instead of ClientTableRegistry
5
+ * - handlers: plain array (no registry class)
6
6
  * - url: string instead of transport (transport auto-created)
7
7
  * - subscriptions: derived from handler.subscribe (no separate param)
8
8
  * - clientId: auto-generated (no longer required)
@@ -19,8 +19,8 @@ import { extractScopeVars } from '@syncular/core';
19
19
  import { createHttpTransport } from '@syncular/transport-http';
20
20
  import type { Kysely } from 'kysely';
21
21
  import { Client } from './client';
22
+ import { createClientHandlerCollection } from './handlers/collection';
22
23
  import { createClientHandler } from './handlers/create-handler';
23
- import { ClientTableRegistry } from './handlers/registry';
24
24
  import type { ClientTableHandler } from './handlers/types';
25
25
  import type { SyncClientDb } from './schema';
26
26
  import { randomUUID } from './utils/id';
@@ -63,24 +63,34 @@ function createAutoHandler<
63
63
  table: string,
64
64
  scopes: string[],
65
65
  options: {
66
- columnCodecs?: ColumnCodecSource;
66
+ codecs?: ColumnCodecSource;
67
67
  codecDialect?: ColumnCodecDialect;
68
68
  }
69
69
  ): ClientTableHandler<DB, TableName> {
70
70
  return createClientHandler<DB, TableName>({
71
71
  table: table as TableName,
72
72
  scopes: scopes as ScopeDefinition[],
73
- columnCodecs: options.columnCodecs,
73
+ codecs: options.codecs,
74
74
  codecDialect: options.codecDialect,
75
75
  });
76
76
  }
77
77
 
78
+ function normalizeTransportBaseUrl(url: string): string {
79
+ const trimmed = url.trim().replace(/\/+$/, '');
80
+ if (!trimmed.endsWith('/sync')) {
81
+ return trimmed;
82
+ }
83
+
84
+ const baseUrl = trimmed.slice(0, -'/sync'.length);
85
+ return baseUrl.length > 0 ? baseUrl : '/';
86
+ }
87
+
78
88
  interface CreateClientOptions<DB extends SyncClientDb> {
79
89
  /** Kysely database instance */
80
90
  db: Kysely<DB>;
81
91
 
82
92
  /**
83
- * Server URL (e.g., '/api/sync' or 'https://api.example.com').
93
+ * Sync URL (e.g., '/api/sync') or base API URL (e.g., '/api').
84
94
  * Defaults to '/api/sync' if not provided.
85
95
  * Ignored if transport is provided.
86
96
  */
@@ -88,7 +98,6 @@ interface CreateClientOptions<DB extends SyncClientDb> {
88
98
 
89
99
  /**
90
100
  * Table handlers for applying snapshots and changes.
91
- * Array is auto-converted to ClientTableRegistry.
92
101
  * Handlers with `subscribe: true` (or an object) are synced.
93
102
  * Handlers with `subscribe: false` are local-only.
94
103
  * Either handlers or tables must be provided.
@@ -144,7 +153,7 @@ interface CreateClientOptions<DB extends SyncClientDb> {
144
153
  stateId?: string;
145
154
 
146
155
  /** Optional: Column codec resolver */
147
- columnCodecs?: ColumnCodecSource;
156
+ codecs?: ColumnCodecSource;
148
157
 
149
158
  /** Optional: Codec dialect override (default: 'sqlite') */
150
159
  codecDialect?: ColumnCodecDialect;
@@ -211,7 +220,7 @@ export async function createClient<DB extends SyncClientDb>(
211
220
  blobStorage,
212
221
  plugins,
213
222
  stateId,
214
- columnCodecs,
223
+ codecs,
215
224
  codecDialect,
216
225
  autoStart = true,
217
226
  } = options;
@@ -229,22 +238,18 @@ export async function createClient<DB extends SyncClientDb>(
229
238
  providedHandlers ??
230
239
  tables!.map((table) =>
231
240
  createAutoHandler<DB, keyof DB & string>(table, scopes!, {
232
- columnCodecs,
241
+ codecs,
233
242
  codecDialect,
234
243
  })
235
244
  );
236
245
 
237
- // Build registry from handlers array
238
- const tableHandlers = new ClientTableRegistry<DB>();
239
- for (const handler of handlers) {
240
- tableHandlers.register(handler);
241
- }
246
+ const tableHandlers = createClientHandlerCollection(handlers);
242
247
 
243
248
  // Create transport from URL if not provided
244
249
  let transport = customTransport;
245
250
  if (!transport && url) {
246
251
  transport = createHttpTransport({
247
- baseUrl: url,
252
+ baseUrl: normalizeTransportBaseUrl(url),
248
253
  getHeaders,
249
254
  });
250
255
  }
@@ -306,7 +311,7 @@ export async function createClient<DB extends SyncClientDb>(
306
311
  blobStorage,
307
312
  plugins,
308
313
  stateId,
309
- columnCodecs,
314
+ codecs,
310
315
  codecDialect,
311
316
  realtimeEnabled: sync.realtime ?? true,
312
317
  pollIntervalMs: sync.pollIntervalMs,