@syncular/client 0.20.0 → 0.21.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/README.md CHANGED
@@ -321,10 +321,10 @@ they are never encoded on the wire or exposed through pending commits, outcome
321
321
  envelopes, diagnostics, preferences, or telemetry.
322
322
 
323
323
  Use `patch(table, rowId, partial, { baseVersion? })` for editor-style partial
324
- updates. The wire still carries a full row, but the durable local operation
325
- records a sorted `changedFields` list so conflict and rejection UI knows which
326
- fields the user intended to touch. That intent is local-only and never enters
327
- `PUSH_COMMIT`; full-row `mutate` operations omit it.
324
+ updates. The wire carries a sparse operation: the primary key plus the supplied
325
+ columns are present, and the durable local operation keeps the same presence
326
+ set so conflict and rejection UI knows which columns the user touched. A
327
+ full-row `mutate` marks every column present.
328
328
 
329
329
  ## Application-authorized local security purge
330
330
 
@@ -676,7 +676,8 @@ through the core, without intermediate download updates.
676
676
 
677
677
  Import reports every 1,024 rows and its final count. `rowsProcessed` includes work
678
678
  inside the current transaction; a failed import can roll those rows back. SQLite
679
- images retain one atomic transaction. `complete` follows checkpoint persistence
679
+ images commit in chunks of at most 1,024 rows and yield after each commit.
680
+ Earlier committed chunks survive a later interruption. `complete` follows checkpoint persistence
680
681
  and optimistic-state reconciliation for the round. More bootstrap pages can
681
682
  remain; use subscription coverage to determine whether a query is complete.
682
683
  Listener exceptions do not fail sync. Listeners should hand UI work off and avoid
@@ -723,3 +724,19 @@ request. Status and diagnostics use `COUNT(*)` without parsing pending bodies.
723
724
  Optimistic replay still reads the remaining outbox after each response. The
724
725
  100/1,000/10,000-commit workload and measured limits are recorded in
725
726
  [the reliability RFC](../../docs/RFC-RELIABILITY-DX.md#9-implementation-evidence-2026-09-05).
727
+
728
+ ## Reads during bootstrap and eviction
729
+
730
+ Reactive queries read cached snapshots while window registration runs. Coverage
731
+ continues to distinguish complete, pending, and missing windows; registration
732
+ errors remain observable. Retained owners reuse acknowledged units immediately.
733
+
734
+ Both segment formats yield between committed import chunks automatically.
735
+ Window eviction deletes at most 1,024 unpinned rows per chunk and resumes from a
736
+ durable pending record after interruption. Security purges remain atomic.
737
+ See [windowing](https://syncular.dev/concepts-windowing/) and
738
+ [bootstrap](https://syncular.dev/concepts-bootstrap/) for coverage and recovery.
739
+
740
+ Custom database adapters implementing `withSqliteImage` must return a promise and
741
+ await their callback before detaching the image. The callback can await between
742
+ committed import chunks; no write transaction spans that await.
package/dist/apply.d.ts CHANGED
@@ -43,12 +43,15 @@ export declare function deleteScopedRows(db: ClientDatabase, table: CompiledClie
43
43
  * scopes (same local-scope-column rule and fail-closed clause as
44
44
  * {@link deleteScopedRows}) EXCEPT rows whose primary key is in
45
45
  * `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
46
- * `true` iff any pinned row was left behind, so the caller knows to defer
47
- * the rest of the eviction until the outbox drains. Also removes the
46
+ * whether unpinned work remains and whether any scoped rows remain. Each
47
+ * call removes at most 1,024 rows; the caller commits before yielding. Also removes the
48
48
  * evicted rows' `server_version` with them (E2 — no residual version
49
49
  * cache), which is automatic since the version column is per-row.
50
50
  */
51
- export declare function evictScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap, pinnedRowIds: ReadonlySet<string>): boolean;
51
+ export declare function evictScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap, pinnedRowIds: ReadonlySet<string>): {
52
+ remaining: boolean;
53
+ deferred: boolean;
54
+ };
52
55
  /** Descriptor fields a sqlite image is validated against (§5.3). */
53
56
  export interface SqliteSegmentDescriptor {
54
57
  readonly table: string;
@@ -57,11 +60,11 @@ export interface SqliteSegmentDescriptor {
57
60
  readonly scopeDigest: string;
58
61
  }
59
62
  /**
60
- * Apply a §5.3 sqlite-image segment in ONE local transaction: validate
63
+ * Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
61
64
  * the in-file metadata against the descriptor, validate the data table's
62
65
  * column names/order against the generated schema, run the §5.6
63
- * first-page clear when fresh, then copy every row with a single
64
- * one primary-key upsert — `_syncular_version` lands in
66
+ * first-page clear when fresh, then bulk-copy each chunk through a
67
+ * primary-key upsert — `_syncular_version` lands in
65
68
  * `_sync_version` exactly like a rows segment's per-row `serverVersion`.
66
69
  */
67
70
  export declare function applySqliteSegment(db: ClientDatabase, schema: CompiledClientSchema, table: CompiledClientTable, bytes: Uint8Array, descriptor: SqliteSegmentDescriptor, options: {
@@ -69,7 +72,8 @@ export declare function applySqliteSegment(db: ClientDatabase, schema: CompiledC
69
72
  readonly effective: ScopeMap;
70
73
  readonly transaction?: ApplyTransaction;
71
74
  readonly onProgress?: (rowsProcessed: number) => void;
72
- }): number;
75
+ readonly isCurrent?: () => boolean;
76
+ }): Promise<number>;
73
77
  /**
74
78
  * Apply a decoded rows segment: each block in one local transaction
75
79
  * (§5.2/§1.4); `clearFirst` implements the §5.6 fresh-bootstrap first-page
@@ -83,4 +87,7 @@ export declare function applyRowsSegment(db: ClientDatabase, schema: CompiledCli
83
87
  readonly effective: ScopeMap;
84
88
  readonly transaction?: ApplyTransaction;
85
89
  readonly onProgress?: (rowsProcessed: number) => void;
90
+ readonly isCurrent?: () => boolean;
86
91
  }, encryption?: EncryptionConfig): Promise<number>;
92
+ /** Let worker messages run only after the current SQLite transaction commits. */
93
+ export declare function yieldToHost(): Promise<void>;
package/dist/apply.js CHANGED
@@ -138,15 +138,15 @@ export function deleteScopedRows(db, table, effective) {
138
138
  * scopes (same local-scope-column rule and fail-closed clause as
139
139
  * {@link deleteScopedRows}) EXCEPT rows whose primary key is in
140
140
  * `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
141
- * `true` iff any pinned row was left behind, so the caller knows to defer
142
- * the rest of the eviction until the outbox drains. Also removes the
141
+ * whether unpinned work remains and whether any scoped rows remain. Each
142
+ * call removes at most 1,024 rows; the caller commits before yielding. Also removes the
143
143
  * evicted rows' `server_version` with them (E2 — no residual version
144
144
  * cache), which is automatic since the version column is per-row.
145
145
  */
146
146
  export function evictScopedRows(db, table, effective, pinnedRowIds) {
147
147
  const entries = Object.entries(effective);
148
148
  if (entries.length === 0)
149
- return false;
149
+ return { remaining: false, deferred: false };
150
150
  const clauses = [];
151
151
  const params = [];
152
152
  for (const [variable, values] of entries) {
@@ -155,7 +155,7 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
155
155
  throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`);
156
156
  }
157
157
  if (values.length === 0)
158
- return false;
158
+ return { remaining: false, deferred: false };
159
159
  clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
160
160
  params.push(...values);
161
161
  }
@@ -166,31 +166,28 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
166
166
  pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
167
167
  params.push(...ids);
168
168
  }
169
- db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}${pinnedClause}`, params);
170
- if (pinnedRowIds.size === 0)
171
- return false;
172
- // A pin still matters only if a pinned row actually falls inside this
173
- // unit's effective scopes; check by re-selecting the survivors.
174
- const survivors = db.query(`SELECT ${pk} AS pk FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params.slice(0, params.length - pinnedRowIds.size));
175
- for (const row of survivors) {
176
- if (pinnedRowIds.has(String(row.pk)))
177
- return true;
178
- }
179
- return false;
169
+ const target = quoteIdent(table.name);
170
+ db.exec(`DELETE FROM ${target} WHERE ${pk} IN (
171
+ SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
172
+ )`, params);
173
+ const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`, params).length > 0;
174
+ const deferred = remaining ||
175
+ db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
176
+ return { remaining, deferred };
180
177
  }
181
178
  const IMAGE_ALIAS = 'syncular_image';
182
179
  function imageInvalid(detail) {
183
180
  throw new ClientSyncError('sync.invalid_request', `sqlite segment rejected: ${detail} (§5.3)`);
184
181
  }
185
182
  /**
186
- * Apply a §5.3 sqlite-image segment in ONE local transaction: validate
183
+ * Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
187
184
  * the in-file metadata against the descriptor, validate the data table's
188
185
  * column names/order against the generated schema, run the §5.6
189
- * first-page clear when fresh, then copy every row with a single
190
- * one primary-key upsert — `_syncular_version` lands in
186
+ * first-page clear when fresh, then bulk-copy each chunk through a
187
+ * primary-key upsert — `_syncular_version` lands in
191
188
  * `_sync_version` exactly like a rows segment's per-row `serverVersion`.
192
189
  */
193
- export function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
190
+ export async function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
194
191
  const withImage = db.withSqliteImage?.bind(db);
195
192
  if (withImage === undefined) {
196
193
  throw new ClientSyncError('sync.invalid_request', 'received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)');
@@ -198,7 +195,7 @@ export function applySqliteSegment(db, schema, table, bytes, descriptor, options
198
195
  if (descriptor.table !== table.name) {
199
196
  imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
200
197
  }
201
- return withImage(bytes, IMAGE_ALIAS, () => {
198
+ return withImage(bytes, IMAGE_ALIAS, async () => {
202
199
  // 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
203
200
  // SQLite database or lacks the metadata table fails right here.
204
201
  let meta;
@@ -244,51 +241,58 @@ export function applySqliteSegment(db, schema, table, bytes, descriptor, options
244
241
  expected.some((name, index) => actual[index] !== name)) {
245
242
  throw new ClientSyncError('sync.schema_mismatch', `sqlite segment for ${JSON.stringify(table.name)} does not match the generated schema: columns [${actual.join(', ')}] (§5.3)`);
246
243
  }
247
- // 3. One transaction: fresh-bootstrap clear, then primary-key upsert.
244
+ const primaryKeys = info.filter((column) => Number(column.pk) > 0);
245
+ if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
246
+ imageInvalid('image primary key does not match generated schema');
247
+ const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
248
+ const count = Number(db.query(`SELECT count(*) AS n FROM ${source}`)[0]?.n);
249
+ if (count !== descriptor.rowCount)
250
+ imageInvalid('image row count does not match descriptor');
248
251
  const names = table.columns.map((column) => quoteIdent(column.name));
249
252
  const insertNames = [...names, quoteIdent(SYNC_VERSION_COLUMN)];
253
+ const primaryKey = quoteIdent(table.primaryKey);
250
254
  const updates = insertNames
251
- .filter((name) => name !== quoteIdent(table.primaryKey))
255
+ .filter((name) => name !== primaryKey)
252
256
  .map((name) => `${name}=excluded.${name}`)
253
257
  .join(', ');
254
- return (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
255
- if (options.clearFirst) {
256
- deleteScopedRows(db, table, options.effective);
258
+ let after;
259
+ let applied = 0;
260
+ for (;;) {
261
+ if (options.isCurrent?.() === false)
262
+ return applied;
263
+ const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source}
264
+ ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
265
+ ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
266
+ if (boundary === null)
267
+ imageInvalid('image primary key must not be null');
268
+ const predicates = [];
269
+ const params = [];
270
+ if (after !== undefined) {
271
+ predicates.push(`${primaryKey} > ?`);
272
+ params.push(after);
273
+ }
274
+ if (boundary !== undefined) {
275
+ predicates.push(`${primaryKey} <= ?`);
276
+ params.push(boundary);
257
277
  }
258
- const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
259
- const primaryKey = quoteIdent(table.primaryKey);
260
- let after;
261
- let applied = 0;
262
- for (;;) {
263
- const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source}
264
- ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
265
- ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
266
- const predicates = [];
267
- const params = [];
268
- if (after !== undefined) {
269
- predicates.push(`${primaryKey} > ?`);
270
- params.push(after);
271
- }
272
- if (boundary !== undefined) {
273
- predicates.push(`${primaryKey} <= ?`);
274
- params.push(boundary);
275
- }
278
+ (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
279
+ if (after === undefined && options.clearFirst)
280
+ deleteScopedRows(db, table, options.effective);
276
281
  db.exec(`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
277
282
  SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
278
283
  FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
279
284
  ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`, params);
280
285
  applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
281
- if (boundary === undefined)
282
- break;
283
- options.onProgress?.(applied);
284
- after = boundary;
285
- }
286
- if (applied !== descriptor.rowCount) {
287
- imageInvalid(`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`);
288
- }
286
+ });
289
287
  options.onProgress?.(applied);
290
- return applied;
291
- });
288
+ await yieldToHost();
289
+ if (boundary === undefined || applied === count)
290
+ break;
291
+ after = boundary;
292
+ }
293
+ if (applied !== count)
294
+ imageInvalid('image applied row count does not match descriptor');
295
+ return applied;
292
296
  });
293
297
  }
294
298
  /**
@@ -305,6 +309,8 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
305
309
  let first = true;
306
310
  const blocks = segment.blocks.length > 0 ? segment.blocks : [[]];
307
311
  for (const block of blocks) {
312
+ if (options.isCurrent?.() === false)
313
+ break;
308
314
  // §5.11: decrypt this block's rows before opening the sync transaction
309
315
  // (WebCrypto is async; the SQLite transaction is not). Decrypt failure
310
316
  // aborts before any write in this block.
@@ -318,6 +324,8 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
318
324
  : row.values;
319
325
  rows.push({ values, serverVersion: row.serverVersion });
320
326
  }
327
+ if (options.isCurrent?.() === false)
328
+ break;
321
329
  const clearThisBlock = first && options.clearFirst;
322
330
  first = false;
323
331
  if (!clearThisBlock && rows.length === 0)
@@ -333,7 +341,21 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
333
341
  options.onProgress?.(applied);
334
342
  }
335
343
  });
344
+ options.onProgress?.(applied);
345
+ await yieldToHost();
336
346
  }
337
347
  options.onProgress?.(applied);
338
348
  return applied;
339
349
  }
350
+ /** Let worker messages run only after the current SQLite transaction commits. */
351
+ export function yieldToHost() {
352
+ return new Promise((resolve) => {
353
+ const channel = new MessageChannel();
354
+ channel.port1.onmessage = () => {
355
+ channel.port1.close();
356
+ channel.port2.close();
357
+ resolve();
358
+ };
359
+ channel.port2.postMessage(null);
360
+ });
361
+ }
@@ -21,7 +21,7 @@ export declare class BunClientDatabase implements ClientDatabase {
21
21
  * §5.3 image import: bun:sqlite attaches files, not buffers, so the
22
22
  * image lands in a private temp file for the duration of the ATTACH.
23
23
  */
24
- withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
24
+ withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T | Promise<T>): Promise<T>;
25
25
  close(): void;
26
26
  }
27
27
  export declare function openBunDatabase(path?: string): ClientDatabase;
@@ -50,7 +50,7 @@ export class BunClientDatabase {
50
50
  * §5.3 image import: bun:sqlite attaches files, not buffers, so the
51
51
  * image lands in a private temp file for the duration of the ATTACH.
52
52
  */
53
- withSqliteImage(bytes, alias, fn) {
53
+ async withSqliteImage(bytes, alias, fn) {
54
54
  assertImageAlias(alias);
55
55
  const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
56
56
  const path = join(dir, 'segment.db');
@@ -58,7 +58,7 @@ export class BunClientDatabase {
58
58
  writeFileSync(path, bytes);
59
59
  this.db.run(`ATTACH DATABASE ? AS ${alias}`, [path]);
60
60
  try {
61
- return fn();
61
+ return await fn();
62
62
  }
63
63
  finally {
64
64
  this.db.run(`DETACH DATABASE ${alias}`);
package/dist/client.d.ts CHANGED
@@ -91,8 +91,8 @@ export interface SyncClientLimits {
91
91
  readonly maxSnapshotPages?: number;
92
92
  /**
93
93
  * §4.2 accept bitmask; defaults to inline + external rows (0b0011)
94
- * plus sqlite images (bit 2) when the database backend implements
95
- * `withSqliteImage` and a segment downloader is configured (§5.3).
94
+ * plus SQLite images and signed URLs when supported. Both image chunks
95
+ * and rows blocks commit before yielding to local reads (§7.7).
96
96
  */
97
97
  readonly accept?: number;
98
98
  /**
@@ -383,8 +383,8 @@ export declare class SyncClient {
383
383
  * Set the live window units for a base (§4.8): a value-sharded family of
384
384
  * subscriptions, one per unit. Computes the diff against the registry —
385
385
  * added units get fresh subscriptions (image-lane bootstrap on the next
386
- * sync); removed units are unsubscribed and evicted, fused in one local
387
- * transaction (E1–E4). Idempotent: calling with the same units is a
386
+ * sync); removed units are unsubscribed with their first eviction chunk
387
+ * in one local transaction (E1–E4). Idempotent: calling with the same units is a
388
388
  * no-op. Re-entry (a unit removed then re-added) cancels any deferred
389
389
  * eviction and fresh-bootstraps.
390
390
  *
@@ -415,12 +415,12 @@ export declare class SyncClient {
415
415
  /** Host-facing mutation result with explicit network work intent (§7.5). */
416
416
  mutateCommand(mutations: readonly MutationInput[]): CommandResult<string>;
417
417
  /**
418
- * Partial-update convenience over the §6.1 full-row wire: read the
419
- * current LOCAL row, merge `partial` over it, and record one full-row
420
- * upsert through `mutate()`. `partial` keys follow the same two-casing
421
- * rule as mutation values (snake_case or camelCase). The row must be
422
- * locally present (subscribed/windowed-in); patching an absent row is
423
- * an error — there is no base to merge into.
418
+ * Partial update over the §6.1 sparse wire: record one upsert whose
419
+ * presence set is the primary key plus the supplied non-scope columns.
420
+ * `partial` keys follow the same two-casing rule as mutation values
421
+ * (snake_case or camelCase). Absent columns keep their stored value on
422
+ * the server and in the local overlay; when the local row is absent the
423
+ * overlay leaves it absent (§7.1) and the server answers per §5.2.
424
424
  */
425
425
  patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
426
426
  readonly baseVersion?: number;