@syncular/client 0.34.0 → 0.36.0

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/dist/client.d.ts CHANGED
@@ -553,6 +553,14 @@ export declare class SyncClient {
553
553
  subscription(id: string): SubscriptionRecord | undefined;
554
554
  pendingCommits(): OutboxCommit[];
555
555
  subscribe(input: SubscribeInput): void;
556
+ /**
557
+ * Register several subscriptions in one local transaction: the same rules
558
+ * as `subscribe` for each input, and one commit for all of them (on OPFS a
559
+ * commit costs milliseconds, so a cold start with hundreds of
560
+ * registrations pays it once). An error rolls back every registration of
561
+ * the call.
562
+ */
563
+ subscribeMany(inputs: readonly SubscribeInput[]): void;
556
564
  unsubscribe(id: string): void;
557
565
  /**
558
566
  * Set the live window units for a base (§4.8): a value-sharded family of
package/dist/client.js CHANGED
@@ -1824,34 +1824,52 @@ export class SyncClient {
1824
1824
  }
1825
1825
  // -- subscriptions ----------------------------------------------------------
1826
1826
  subscribe(input) {
1827
+ this.subscribeMany([input]);
1828
+ }
1829
+ /**
1830
+ * Register several subscriptions in one local transaction: the same rules
1831
+ * as `subscribe` for each input, and one commit for all of them (on OPFS a
1832
+ * commit costs milliseconds, so a cold start with hundreds of
1833
+ * registrations pays it once). An error rolls back every registration of
1834
+ * the call.
1835
+ */
1836
+ subscribeMany(inputs) {
1827
1837
  this.#requireActive();
1828
- if (!this.#schema.tables.has(input.table)) {
1829
- throw new ClientSyncError('sync.unknown_table', `subscribe: unknown local table ${JSON.stringify(input.table)}`);
1830
- }
1831
- const existing = getSubscription(this.#db, input.id);
1832
- if (existing !== undefined) {
1833
- const sameIntent = existing.table === input.table &&
1834
- canonicalScopeJson(existing.scopes) ===
1835
- canonicalScopeJson(input.scopes) &&
1836
- existing.params === input.params;
1837
- if (!sameIntent) {
1838
- throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
1838
+ let registered = false;
1839
+ this.#db.transaction(() => {
1840
+ for (const input of inputs) {
1841
+ if (!this.#schema.tables.has(input.table)) {
1842
+ throw new ClientSyncError('sync.unknown_table', `subscribe: unknown local table ${JSON.stringify(input.table)}`);
1843
+ }
1844
+ const existing = getSubscription(this.#db, input.id);
1845
+ if (existing !== undefined) {
1846
+ const sameIntent = existing.table === input.table &&
1847
+ canonicalScopeJson(existing.scopes) ===
1848
+ canonicalScopeJson(input.scopes) &&
1849
+ existing.params === input.params;
1850
+ if (!sameIntent) {
1851
+ throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
1852
+ }
1853
+ // §3.3: a revoked registration keeps its id and intent but no
1854
+ // progress. Subscribing again with the same intent re-arms it, so
1855
+ // the next pull resolves its scopes from scratch. A `failed`
1856
+ // registration is a configuration error and stays put.
1857
+ if (existing.status !== 'revoked')
1858
+ continue;
1859
+ }
1860
+ saveSubscription(this.#db, definedObject({
1861
+ id: input.id,
1862
+ table: input.table,
1863
+ scopes: input.scopes,
1864
+ params: input.params,
1865
+ cursor: -1,
1866
+ status: 'active',
1867
+ }));
1868
+ registered = true;
1839
1869
  }
1840
- // §3.3: a revoked registration keeps its id and intent but no progress.
1841
- // Subscribing again with the same intent re-arms it, so the next pull
1842
- // resolves its scopes from scratch. A `failed` registration is a
1843
- // configuration error and stays put.
1844
- if (existing.status !== 'revoked')
1845
- return;
1846
- }
1847
- saveSubscription(this.#db, definedObject({
1848
- id: input.id,
1849
- table: input.table,
1850
- scopes: input.scopes,
1851
- params: input.params,
1852
- cursor: -1,
1853
- status: 'active',
1854
- }));
1870
+ });
1871
+ if (!registered)
1872
+ return;
1855
1873
  this.#setSyncNeeded(true);
1856
1874
  this.#emitSyncIntent({ kind: 'interactive' });
1857
1875
  this.#emitDiagnostics();
@@ -300,6 +300,7 @@ export function startSyncWorker(overrides = {}) {
300
300
  const coreMethods = [
301
301
  'securityLifecycle',
302
302
  'subscribe',
303
+ 'subscribeMany',
303
304
  'unsubscribe',
304
305
  'windowState',
305
306
  'purgeLocalData',
@@ -224,6 +224,7 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
224
224
  onRoleChange(listener: (role: HandleRole) => void): () => void;
225
225
  onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
226
226
  subscribe(input: SubscribeInput): Promise<void>;
227
+ subscribeMany(inputs: readonly SubscribeInput[]): Promise<void>;
227
228
  securityLifecycle(): Promise<SecurityLifecycle>;
228
229
  beginSecurityPreflight(): Promise<void>;
229
230
  activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
@@ -283,6 +283,9 @@ export class SyncClientHandle {
283
283
  subscribe(input) {
284
284
  return this.#call('subscribe', [input]);
285
285
  }
286
+ subscribeMany(inputs) {
287
+ return this.#call('subscribeMany', [inputs]);
288
+ }
286
289
  securityLifecycle() {
287
290
  return this.#call('securityLifecycle', []);
288
291
  }
@@ -113,6 +113,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
113
113
  beginSecurityPreflight(): Promise<void>;
114
114
  activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
115
115
  subscribe(input: SubscribeInput): void;
116
+ subscribeMany(inputs: readonly SubscribeInput[]): void;
116
117
  unsubscribe(id: string): void;
117
118
  /** §4.8 windowed subscriptions: set the live units for a window base. */
118
119
  setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -97,10 +97,10 @@
97
97
  },
98
98
  "dependencies": {
99
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
100
- "@syncular/core": "0.34.0"
100
+ "@syncular/core": "0.36.0"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.34.0",
103
+ "@syncular/server": "0.36.0",
104
104
  "playwright": "1.63.0",
105
105
  "@electric-sql/pglite": "^0.5.4"
106
106
  }
package/src/client.ts CHANGED
@@ -2894,43 +2894,61 @@ export class SyncClient {
2894
2894
  // -- subscriptions ----------------------------------------------------------
2895
2895
 
2896
2896
  subscribe(input: SubscribeInput): void {
2897
+ this.subscribeMany([input]);
2898
+ }
2899
+
2900
+ /**
2901
+ * Register several subscriptions in one local transaction: the same rules
2902
+ * as `subscribe` for each input, and one commit for all of them (on OPFS a
2903
+ * commit costs milliseconds, so a cold start with hundreds of
2904
+ * registrations pays it once). An error rolls back every registration of
2905
+ * the call.
2906
+ */
2907
+ subscribeMany(inputs: readonly SubscribeInput[]): void {
2897
2908
  this.#requireActive();
2898
- if (!this.#schema.tables.has(input.table)) {
2899
- throw new ClientSyncError(
2900
- 'sync.unknown_table',
2901
- `subscribe: unknown local table ${JSON.stringify(input.table)}`,
2902
- );
2903
- }
2904
- const existing = getSubscription(this.#db, input.id);
2905
- if (existing !== undefined) {
2906
- const sameIntent =
2907
- existing.table === input.table &&
2908
- canonicalScopeJson(existing.scopes) ===
2909
- canonicalScopeJson(input.scopes) &&
2910
- existing.params === input.params;
2911
- if (!sameIntent) {
2912
- throw new ClientSyncError(
2913
- 'client.subscription_intent_mismatch',
2914
- 'subscription id already names different table, scopes, or params',
2909
+ let registered = false;
2910
+ this.#db.transaction(() => {
2911
+ for (const input of inputs) {
2912
+ if (!this.#schema.tables.has(input.table)) {
2913
+ throw new ClientSyncError(
2914
+ 'sync.unknown_table',
2915
+ `subscribe: unknown local table ${JSON.stringify(input.table)}`,
2916
+ );
2917
+ }
2918
+ const existing = getSubscription(this.#db, input.id);
2919
+ if (existing !== undefined) {
2920
+ const sameIntent =
2921
+ existing.table === input.table &&
2922
+ canonicalScopeJson(existing.scopes) ===
2923
+ canonicalScopeJson(input.scopes) &&
2924
+ existing.params === input.params;
2925
+ if (!sameIntent) {
2926
+ throw new ClientSyncError(
2927
+ 'client.subscription_intent_mismatch',
2928
+ 'subscription id already names different table, scopes, or params',
2929
+ );
2930
+ }
2931
+ // §3.3: a revoked registration keeps its id and intent but no
2932
+ // progress. Subscribing again with the same intent re-arms it, so
2933
+ // the next pull resolves its scopes from scratch. A `failed`
2934
+ // registration is a configuration error and stays put.
2935
+ if (existing.status !== 'revoked') continue;
2936
+ }
2937
+ saveSubscription(
2938
+ this.#db,
2939
+ definedObject({
2940
+ id: input.id,
2941
+ table: input.table,
2942
+ scopes: input.scopes,
2943
+ params: input.params,
2944
+ cursor: -1,
2945
+ status: 'active',
2946
+ }),
2915
2947
  );
2948
+ registered = true;
2916
2949
  }
2917
- // §3.3: a revoked registration keeps its id and intent but no progress.
2918
- // Subscribing again with the same intent re-arms it, so the next pull
2919
- // resolves its scopes from scratch. A `failed` registration is a
2920
- // configuration error and stays put.
2921
- if (existing.status !== 'revoked') return;
2922
- }
2923
- saveSubscription(
2924
- this.#db,
2925
- definedObject({
2926
- id: input.id,
2927
- table: input.table,
2928
- scopes: input.scopes,
2929
- params: input.params,
2930
- cursor: -1,
2931
- status: 'active',
2932
- }),
2933
- );
2950
+ });
2951
+ if (!registered) return;
2934
2952
  this.#setSyncNeeded(true);
2935
2953
  this.#emitSyncIntent({ kind: 'interactive' });
2936
2954
  this.#emitDiagnostics();
@@ -415,6 +415,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
415
415
  const coreMethods = [
416
416
  'securityLifecycle',
417
417
  'subscribe',
418
+ 'subscribeMany',
418
419
  'unsubscribe',
419
420
  'windowState',
420
421
  'purgeLocalData',
@@ -576,6 +576,10 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
576
576
  return this.#call('subscribe', [input]);
577
577
  }
578
578
 
579
+ subscribeMany(inputs: readonly SubscribeInput[]): Promise<void> {
580
+ return this.#call('subscribeMany', [inputs]);
581
+ }
582
+
579
583
  securityLifecycle(): Promise<SecurityLifecycle> {
580
584
  return this.#call('securityLifecycle', []);
581
585
  }
@@ -158,6 +158,7 @@ export interface WorkerApi extends Omit<
158
158
  beginSecurityPreflight(): Promise<void>;
159
159
  activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
160
160
  subscribe(input: SubscribeInput): void;
161
+ subscribeMany(inputs: readonly SubscribeInput[]): void;
161
162
  unsubscribe(id: string): void;
162
163
  /** §4.8 windowed subscriptions: set the live units for a window base. */
163
164
  setWindow(base: WindowBase, units: readonly string[]): Promise<void>;