@syncular/client 0.3.0 → 0.4.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
@@ -182,8 +182,20 @@ export interface SubscribeInput {
182
182
  export interface WindowState {
183
183
  /** Windowed-in units for this base, ordered by value. */
184
184
  readonly units: readonly string[];
185
+ /**
186
+ * Registered units whose bootstrap has not yet completed (§4.8): the
187
+ * unit's subscription still has `cursor: -1` (never synced) or holds a
188
+ * resume token (mid-bootstrap). Between `setWindow` and the bootstrap
189
+ * landing, the local replica for a pending unit may be empty or partial —
190
+ * it MUST NOT be rendered as complete.
191
+ */
192
+ readonly pending: readonly string[];
185
193
  }
186
- /** True iff `unit` is windowed-in for this snapshot (a registry hit, I3). */
194
+ /**
195
+ * True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
196
+ * registered and not pending. A unit with zero server rows still becomes
197
+ * complete once its bootstrap round finishes — emptiness ≠ pendency.
198
+ */
187
199
  export declare function windowComplete(state: WindowState, unit: string): boolean;
188
200
  export declare class SyncClient {
189
201
  #private;
@@ -305,10 +317,13 @@ export declare class SyncClient {
305
317
  setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
306
318
  /**
307
319
  * The completeness oracle (§4.8 I3): which units of a base are windowed-in
308
- * locally, and a per-unit verdict a live query renders "may be partial"
309
- * from. A query whose scope footprint includes an un-windowed unit is
320
+ * locally, which of those are still bootstrap-pending, and thereby the
321
+ * per-unit verdict a live query renders "may be partial" from. A query
322
+ * whose scope footprint includes an un-windowed OR still-pending unit is
310
323
  * NOT answerable in full — the host widens or shows partial, never
311
- * silently-complete.
324
+ * silently-complete. Registration alone is not completeness: a unit is
325
+ * pending until its subscription completes a bootstrap round (cursor
326
+ * advances past -1 with no resume token held).
312
327
  */
313
328
  windowState(base: WindowBase): WindowState;
314
329
  /**
package/dist/client.js CHANGED
@@ -19,9 +19,13 @@ import { assertReadOnlyQuery } from './query-guard.js';
19
19
  import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
20
20
  import { deleteSubscription, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
21
21
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
22
- /** True iff `unit` is windowed-in for this snapshot (a registry hit, I3). */
22
+ /**
23
+ * True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
24
+ * registered and not pending. A unit with zero server rows still becomes
25
+ * complete once its bootstrap round finishes — emptiness ≠ pendency.
26
+ */
23
27
  export function windowComplete(state, unit) {
24
- return state.units.includes(unit);
28
+ return state.units.includes(unit) && !state.pending.includes(unit);
25
29
  }
26
30
  // ---------------------------------------------------------------------------
27
31
  // Internals
@@ -645,15 +649,28 @@ export class SyncClient {
645
649
  }
646
650
  /**
647
651
  * The completeness oracle (§4.8 I3): which units of a base are windowed-in
648
- * locally, and a per-unit verdict a live query renders "may be partial"
649
- * from. A query whose scope footprint includes an un-windowed unit is
652
+ * locally, which of those are still bootstrap-pending, and thereby the
653
+ * per-unit verdict a live query renders "may be partial" from. A query
654
+ * whose scope footprint includes an un-windowed OR still-pending unit is
650
655
  * NOT answerable in full — the host widens or shows partial, never
651
- * silently-complete.
656
+ * silently-complete. Registration alone is not completeness: a unit is
657
+ * pending until its subscription completes a bootstrap round (cursor
658
+ * advances past -1 with no resume token held).
652
659
  */
653
660
  windowState(base) {
654
661
  this.#requireStarted();
655
662
  const baseKey = windowBaseKey(base);
656
- return { units: loadWindowUnits(this.#db, baseKey).map((u) => u.unit) };
663
+ const live = loadWindowUnits(this.#db, baseKey);
664
+ const pending = [];
665
+ for (const { unit, subId } of live) {
666
+ const sub = getSubscription(this.#db, subId);
667
+ if (sub === undefined ||
668
+ sub.cursor < 0 ||
669
+ sub.bootstrapState !== undefined) {
670
+ pending.push(unit);
671
+ }
672
+ }
673
+ return { units: live.map((u) => u.unit), pending };
657
674
  }
658
675
  /**
659
676
  * §4.8 E1–E4: evict one departing unit, fused with its unsubscription in
@@ -1600,6 +1617,7 @@ export class SyncClient {
1600
1617
  // §3.3: the effective-scope echo is persisted for the purge contract.
1601
1618
  // An absent bootstrapState clears any previous resume token (§4.4:
1602
1619
  // absent = bootstrap complete, or not bootstrapping).
1620
+ const wasPending = sub.cursor < 0 || sub.bootstrapState !== undefined;
1603
1621
  saveSubscription(this.#db, {
1604
1622
  id: sub.id,
1605
1623
  table: sub.table,
@@ -1610,6 +1628,12 @@ export class SyncClient {
1610
1628
  effectiveScopes: start.effectiveScopes,
1611
1629
  status: 'active',
1612
1630
  });
1631
+ if (wasPending && nextCursor >= 0 && bootstrapState === undefined) {
1632
+ // §4.8: the completeness verdict flipped pending → complete. A
1633
+ // zero-row bootstrap applies nothing, so the flip itself must reach
1634
+ // live oracles through the choke point (shares the pull's batch).
1635
+ this.#applyBatch((batch) => batch.table(sub.table));
1636
+ }
1613
1637
  return true;
1614
1638
  }
1615
1639
  if (start.status === 'reset') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.2.1"
84
+ "@syncular/core": "0.3.1"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.2.1",
95
+ "@syncular/server": "0.3.1",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -309,11 +309,23 @@ export interface SubscribeInput {
309
309
  export interface WindowState {
310
310
  /** Windowed-in units for this base, ordered by value. */
311
311
  readonly units: readonly string[];
312
+ /**
313
+ * Registered units whose bootstrap has not yet completed (§4.8): the
314
+ * unit's subscription still has `cursor: -1` (never synced) or holds a
315
+ * resume token (mid-bootstrap). Between `setWindow` and the bootstrap
316
+ * landing, the local replica for a pending unit may be empty or partial —
317
+ * it MUST NOT be rendered as complete.
318
+ */
319
+ readonly pending: readonly string[];
312
320
  }
313
321
 
314
- /** True iff `unit` is windowed-in for this snapshot (a registry hit, I3). */
322
+ /**
323
+ * True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
324
+ * registered and not pending. A unit with zero server rows still becomes
325
+ * complete once its bootstrap round finishes — emptiness ≠ pendency.
326
+ */
315
327
  export function windowComplete(state: WindowState, unit: string): boolean {
316
- return state.units.includes(unit);
328
+ return state.units.includes(unit) && !state.pending.includes(unit);
317
329
  }
318
330
 
319
331
  // ---------------------------------------------------------------------------
@@ -1069,15 +1081,30 @@ export class SyncClient {
1069
1081
 
1070
1082
  /**
1071
1083
  * The completeness oracle (§4.8 I3): which units of a base are windowed-in
1072
- * locally, and a per-unit verdict a live query renders "may be partial"
1073
- * from. A query whose scope footprint includes an un-windowed unit is
1084
+ * locally, which of those are still bootstrap-pending, and thereby the
1085
+ * per-unit verdict a live query renders "may be partial" from. A query
1086
+ * whose scope footprint includes an un-windowed OR still-pending unit is
1074
1087
  * NOT answerable in full — the host widens or shows partial, never
1075
- * silently-complete.
1088
+ * silently-complete. Registration alone is not completeness: a unit is
1089
+ * pending until its subscription completes a bootstrap round (cursor
1090
+ * advances past -1 with no resume token held).
1076
1091
  */
1077
1092
  windowState(base: WindowBase): WindowState {
1078
1093
  this.#requireStarted();
1079
1094
  const baseKey = windowBaseKey(base);
1080
- return { units: loadWindowUnits(this.#db, baseKey).map((u) => u.unit) };
1095
+ const live = loadWindowUnits(this.#db, baseKey);
1096
+ const pending: string[] = [];
1097
+ for (const { unit, subId } of live) {
1098
+ const sub = getSubscription(this.#db, subId);
1099
+ if (
1100
+ sub === undefined ||
1101
+ sub.cursor < 0 ||
1102
+ sub.bootstrapState !== undefined
1103
+ ) {
1104
+ pending.push(unit);
1105
+ }
1106
+ }
1107
+ return { units: live.map((u) => u.unit), pending };
1081
1108
  }
1082
1109
 
1083
1110
  /**
@@ -2236,6 +2263,7 @@ export class SyncClient {
2236
2263
  // §3.3: the effective-scope echo is persisted for the purge contract.
2237
2264
  // An absent bootstrapState clears any previous resume token (§4.4:
2238
2265
  // absent = bootstrap complete, or not bootstrapping).
2266
+ const wasPending = sub.cursor < 0 || sub.bootstrapState !== undefined;
2239
2267
  saveSubscription(this.#db, {
2240
2268
  id: sub.id,
2241
2269
  table: sub.table,
@@ -2246,6 +2274,12 @@ export class SyncClient {
2246
2274
  effectiveScopes: start.effectiveScopes,
2247
2275
  status: 'active',
2248
2276
  });
2277
+ if (wasPending && nextCursor >= 0 && bootstrapState === undefined) {
2278
+ // §4.8: the completeness verdict flipped pending → complete. A
2279
+ // zero-row bootstrap applies nothing, so the flip itself must reach
2280
+ // live oracles through the choke point (shares the pull's batch).
2281
+ this.#applyBatch((batch) => batch.table(sub.table));
2282
+ }
2249
2283
  return true;
2250
2284
  }
2251
2285