@syncular/client 0.20.0 → 0.20.1

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.
@@ -244,7 +244,8 @@ class QueryEntry {
244
244
  #running = false;
245
245
  #requested = false;
246
246
  #desiredRevision = 0n;
247
- #claimReady = Promise.resolve();
247
+ #claimError;
248
+ #claimPending = false;
248
249
  #offStatus;
249
250
  constructor(store, spec, key, cache) {
250
251
  this.store = store;
@@ -270,10 +271,22 @@ class QueryEntry {
270
271
  for (const coverage of this.spec.coverage ?? []) {
271
272
  claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
272
273
  }
273
- this.#claimReady = Promise.all(claims).then(() => undefined);
274
- // A render can lose its last subscriber before the read loop starts.
275
- // The loop still observes the original rejection while it owns the claim.
276
- void this.#claimReady.catch(() => undefined);
274
+ const generation = this.#generation;
275
+ this.#claimPending = claims.length > 0;
276
+ if (claims.length > 0)
277
+ void Promise.all(claims).then(() => {
278
+ if (generation !== this.#generation || this.#listeners.size === 0)
279
+ return;
280
+ this.#claimPending = false;
281
+ if (!this.#running && this.#state.phase !== 'ready')
282
+ this.#requestRead();
283
+ }, (error) => {
284
+ if (generation !== this.#generation || this.#listeners.size === 0)
285
+ return;
286
+ this.#claimPending = false;
287
+ this.#claimError = errorOf(error);
288
+ this.#requestRead();
289
+ });
277
290
  }
278
291
  this.#requestRead();
279
292
  }
@@ -300,7 +313,8 @@ class QueryEntry {
300
313
  this.#running = false;
301
314
  this.#requested = false;
302
315
  this.#desiredRevision = 0n;
303
- this.#claimReady = Promise.resolve();
316
+ this.#claimError = undefined;
317
+ this.#claimPending = false;
304
318
  this.#state = {
305
319
  rows: [],
306
320
  phase: 'loading',
@@ -393,9 +407,11 @@ class QueryEntry {
393
407
  this.#requested = false;
394
408
  if (this.store.availabilitySnapshot().state === 'blocked')
395
409
  break;
396
- await this.#claimReady;
397
410
  if (generation !== this.#generation || this.#listeners.size === 0)
398
411
  return;
412
+ if (this.#claimError !== undefined)
413
+ throw this.#claimError;
414
+ const claimWasPending = this.#claimPending;
399
415
  const snapshot = await this.store.client.querySnapshot({
400
416
  sql: this.spec.sql,
401
417
  ...(this.spec.params !== undefined
@@ -407,6 +423,8 @@ class QueryEntry {
407
423
  });
408
424
  if (generation !== this.#generation || this.#listeners.size === 0)
409
425
  return;
426
+ if (this.#claimError !== undefined)
427
+ throw this.#claimError;
410
428
  const availability = this.store.availabilitySnapshot();
411
429
  if (availability.state === 'blocked') {
412
430
  this.#publish({
@@ -421,6 +439,10 @@ class QueryEntry {
421
439
  this.#requested = true;
422
440
  continue;
423
441
  }
442
+ if (!snapshot.coverage.complete &&
443
+ claimWasPending &&
444
+ !this.#claimPending)
445
+ this.#requested = true;
424
446
  const mappedRows = this.spec.mapRow === undefined
425
447
  ? snapshot.rows
426
448
  : snapshot.rows.map(this.spec.mapRow);
@@ -736,6 +758,7 @@ export class ReactiveClientStore {
736
758
  base,
737
759
  claims: new Map(),
738
760
  appliedKey: '',
761
+ appliedUnits: new Set(),
739
762
  scheduled: false,
740
763
  running: false,
741
764
  requested: false,
@@ -744,6 +767,13 @@ export class ReactiveClientStore {
744
767
  this.#windowClaims.set(key, group);
745
768
  }
746
769
  group.claims.set(owner, new Set(units));
770
+ const alreadyHeld = units.length > 0 &&
771
+ units.every((unit) => group.appliedUnits.has(unit) &&
772
+ (group.inFlightUnits === undefined || group.inFlightUnits.has(unit)));
773
+ if (alreadyHeld) {
774
+ this.#scheduleWindow(group);
775
+ return Promise.resolve();
776
+ }
747
777
  const result = new Promise((resolve, reject) => {
748
778
  group?.waiters.push({ resolve, reject });
749
779
  });
@@ -779,16 +809,27 @@ export class ReactiveClientStore {
779
809
  ].sort();
780
810
  const key = canonicalValue(units);
781
811
  if (key !== group.appliedKey) {
782
- await this.client.setWindow(group.base, units);
812
+ group.inFlightUnits = new Set(units);
813
+ try {
814
+ await this.client.setWindow(group.base, units);
815
+ }
816
+ finally {
817
+ group.inFlightUnits = undefined;
818
+ }
783
819
  if (this.#windowClaims.get(baseKey) !== group)
784
820
  return;
785
821
  group.appliedKey = key;
822
+ group.appliedUnits = new Set(units);
786
823
  }
787
824
  }
788
825
  for (const waiter of group.waiters.splice(0))
789
826
  waiter.resolve();
790
827
  }
791
828
  catch (error) {
829
+ // A failed window edit may already have committed its first eviction chunk.
830
+ // Reconcile through the core before acknowledging any later claim.
831
+ group.appliedKey = '';
832
+ group.appliedUnits = new Set();
792
833
  for (const waiter of group.waiters.splice(0))
793
834
  waiter.reject(error);
794
835
  }
package/dist/schema.js CHANGED
@@ -234,6 +234,11 @@ function createFtsProjection(db, table, index) {
234
234
  const indexedColumns = index.columns.map(quoteIdent);
235
235
  const tokenizer = index.tokenize.replaceAll("'", "''");
236
236
  db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`);
237
+ const mapping = quoteIdent(`_syncular_fts_${index.name}`);
238
+ const mapped = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
239
+ `_syncular_fts_${index.name}`,
240
+ ]).length > 0;
241
+ db.exec(`CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`);
237
242
  const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
238
243
  const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
239
244
  const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
@@ -245,8 +250,9 @@ function createFtsProjection(db, table, index) {
245
250
  newSourceId,
246
251
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
247
252
  ].join(', ');
248
- const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
249
- const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
253
+ const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
254
+ const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select})); DELETE FROM ${mapping} WHERE source_id IN (${select})`;
255
+ const insertNew = `INSERT INTO ${mapping}(source_id) VALUES (${newSourceId}); INSERT INTO ${quoteIdent(index.name)} (rowid, ${projectionColumns}) VALUES ((SELECT id FROM ${mapping} WHERE source_id = ${newSourceId}), ${newValues})`;
250
256
  // A clean insert cannot already have a projection row because the source
251
257
  // primary key is unique. Keep clean inserts linear by moving replacement
252
258
  // cleanup behind indexed source-table existence checks. SQLite does not
@@ -294,9 +300,9 @@ function createFtsProjection(db, table, index) {
294
300
  ...displacedByUnique.map(deleteDisplaced),
295
301
  ].join('; ');
296
302
  db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`);
297
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`);
303
+ db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`);
298
304
  db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`);
299
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`);
305
+ db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`);
300
306
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
301
307
  // different-PK row about to be displaced through a secondary unique index by
302
308
  // the new values. Only meaningful when the table has a unique index; with
@@ -311,6 +317,10 @@ function createFtsProjection(db, table, index) {
311
317
  if (!existed) {
312
318
  db.exec(`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
313
319
  }
320
+ if (!mapped || !existed) {
321
+ db.exec(`DELETE FROM ${mapping}`);
322
+ db.exec(`INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`);
323
+ }
314
324
  }
315
325
  /**
316
326
  * Create only the application-owned synced tables, indexes, and FTS
@@ -424,6 +434,7 @@ export function dropAndRecreateSyncedTables(db, schema) {
424
434
  AND sql LIKE 'CREATE VIRTUAL TABLE%'
425
435
  AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`);
426
436
  for (const row of virtualTables) {
437
+ db.exec(`DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`);
427
438
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
428
439
  }
429
440
  const existing = db.query(`SELECT name FROM sqlite_master WHERE type = 'table'
@@ -65,9 +65,9 @@ class WasmClientDatabase {
65
65
  * §5.3 image import at near-file-copy speed: attach an empty in-memory
66
66
  * schema, then `sqlite3_deserialize` the image bytes into it (the
67
67
  * documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
68
- * row shuttling before the single INSERT…SELECT the caller runs).
68
+ * row shuttling before the chunked INSERT…SELECT statements the caller runs).
69
69
  */
70
- withSqliteImage(bytes, alias, fn) {
70
+ async withSqliteImage(bytes, alias, fn) {
71
71
  assertImageAlias(alias);
72
72
  const pointer = this.#db.pointer;
73
73
  if (pointer === undefined) {
@@ -84,7 +84,7 @@ class WasmClientDatabase {
84
84
  if (rc !== 0) {
85
85
  throw new ClientSyncError('sync.invalid_request', `sqlite3_deserialize failed with code ${rc} (§5.3)`);
86
86
  }
87
- return fn();
87
+ return await fn();
88
88
  }
89
89
  finally {
90
90
  this.exec(`DETACH ${alias}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
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",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.20.0"
92
+ "@syncular/core": "0.20.1"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.20.0",
95
+ "@syncular/server": "0.20.1",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
package/src/apply.ts CHANGED
@@ -215,8 +215,8 @@ export function deleteScopedRows(
215
215
  * scopes (same local-scope-column rule and fail-closed clause as
216
216
  * {@link deleteScopedRows}) EXCEPT rows whose primary key is in
217
217
  * `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
218
- * `true` iff any pinned row was left behind, so the caller knows to defer
219
- * the rest of the eviction until the outbox drains. Also removes the
218
+ * whether unpinned work remains and whether any scoped rows remain. Each
219
+ * call removes at most 1,024 rows; the caller commits before yielding. Also removes the
220
220
  * evicted rows' `server_version` with them (E2 — no residual version
221
221
  * cache), which is automatic since the version column is per-row.
222
222
  */
@@ -225,9 +225,9 @@ export function evictScopedRows(
225
225
  table: CompiledClientTable,
226
226
  effective: ScopeMap,
227
227
  pinnedRowIds: ReadonlySet<string>,
228
- ): boolean {
228
+ ): { remaining: boolean; deferred: boolean } {
229
229
  const entries = Object.entries(effective);
230
- if (entries.length === 0) return false;
230
+ if (entries.length === 0) return { remaining: false, deferred: false };
231
231
  const clauses: string[] = [];
232
232
  const params: string[] = [];
233
233
  for (const [variable, values] of entries) {
@@ -238,7 +238,7 @@ export function evictScopedRows(
238
238
  `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`,
239
239
  );
240
240
  }
241
- if (values.length === 0) return false;
241
+ if (values.length === 0) return { remaining: false, deferred: false };
242
242
  clauses.push(
243
243
  `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
244
244
  );
@@ -251,21 +251,25 @@ export function evictScopedRows(
251
251
  pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
252
252
  params.push(...ids);
253
253
  }
254
+ const target = quoteIdent(table.name);
254
255
  db.exec(
255
- `DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}${pinnedClause}`,
256
+ `DELETE FROM ${target} WHERE ${pk} IN (
257
+ SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
258
+ )`,
256
259
  params,
257
260
  );
258
- if (pinnedRowIds.size === 0) return false;
259
- // A pin still matters only if a pinned row actually falls inside this
260
- // unit's effective scopes; check by re-selecting the survivors.
261
- const survivors = db.query(
262
- `SELECT ${pk} AS pk FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
263
- params.slice(0, params.length - pinnedRowIds.size),
264
- );
265
- for (const row of survivors) {
266
- if (pinnedRowIds.has(String(row.pk))) return true;
267
- }
268
- return false;
261
+ const remaining =
262
+ db.query(
263
+ `SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`,
264
+ params,
265
+ ).length > 0;
266
+ const deferred =
267
+ remaining ||
268
+ db.query(
269
+ `SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`,
270
+ params.slice(0, params.length - pinnedRowIds.size),
271
+ ).length > 0;
272
+ return { remaining, deferred };
269
273
  }
270
274
 
271
275
  /** Descriptor fields a sqlite image is validated against (§5.3). */
@@ -286,14 +290,14 @@ function imageInvalid(detail: string): never {
286
290
  }
287
291
 
288
292
  /**
289
- * Apply a §5.3 sqlite-image segment in ONE local transaction: validate
293
+ * Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
290
294
  * the in-file metadata against the descriptor, validate the data table's
291
295
  * column names/order against the generated schema, run the §5.6
292
- * first-page clear when fresh, then copy every row with a single
293
- * one primary-key upsert — `_syncular_version` lands in
296
+ * first-page clear when fresh, then bulk-copy each chunk through a
297
+ * primary-key upsert — `_syncular_version` lands in
294
298
  * `_sync_version` exactly like a rows segment's per-row `serverVersion`.
295
299
  */
296
- export function applySqliteSegment(
300
+ export async function applySqliteSegment(
297
301
  db: ClientDatabase,
298
302
  schema: CompiledClientSchema,
299
303
  table: CompiledClientTable,
@@ -304,8 +308,9 @@ export function applySqliteSegment(
304
308
  readonly effective: ScopeMap;
305
309
  readonly transaction?: ApplyTransaction;
306
310
  readonly onProgress?: (rowsProcessed: number) => void;
311
+ readonly isCurrent?: () => boolean;
307
312
  },
308
- ): number {
313
+ ): Promise<number> {
309
314
  const withImage = db.withSqliteImage?.bind(db);
310
315
  if (withImage === undefined) {
311
316
  throw new ClientSyncError(
@@ -316,7 +321,7 @@ export function applySqliteSegment(
316
321
  if (descriptor.table !== table.name) {
317
322
  imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
318
323
  }
319
- return withImage(bytes, IMAGE_ALIAS, () => {
324
+ return withImage(bytes, IMAGE_ALIAS, async () => {
320
325
  // 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
321
326
  // SQLite database or lacks the metadata table fails right here.
322
327
  let meta: ReturnType<ClientDatabase['query']>;
@@ -371,38 +376,44 @@ export function applySqliteSegment(
371
376
  );
372
377
  }
373
378
 
374
- // 3. One transaction: fresh-bootstrap clear, then primary-key upsert.
379
+ const primaryKeys = info.filter((column) => Number(column.pk) > 0);
380
+ if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
381
+ imageInvalid('image primary key does not match generated schema');
382
+ const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
383
+ const count = Number(db.query(`SELECT count(*) AS n FROM ${source}`)[0]?.n);
384
+ if (count !== descriptor.rowCount)
385
+ imageInvalid('image row count does not match descriptor');
375
386
  const names = table.columns.map((column) => quoteIdent(column.name));
376
387
  const insertNames = [...names, quoteIdent(SYNC_VERSION_COLUMN)];
388
+ const primaryKey = quoteIdent(table.primaryKey);
377
389
  const updates = insertNames
378
- .filter((name) => name !== quoteIdent(table.primaryKey))
390
+ .filter((name) => name !== primaryKey)
379
391
  .map((name) => `${name}=excluded.${name}`)
380
392
  .join(', ');
381
- return (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
382
- if (options.clearFirst) {
383
- deleteScopedRows(db, table, options.effective);
393
+ let after: SqlValue | undefined;
394
+ let applied = 0;
395
+ for (;;) {
396
+ if (options.isCurrent?.() === false) return applied;
397
+ const boundary = db.query(
398
+ `SELECT ${primaryKey} AS boundary FROM ${source}
399
+ ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
400
+ ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
401
+ after === undefined ? [] : [after],
402
+ )[0]?.boundary;
403
+ if (boundary === null) imageInvalid('image primary key must not be null');
404
+ const predicates: string[] = [];
405
+ const params: SqlValue[] = [];
406
+ if (after !== undefined) {
407
+ predicates.push(`${primaryKey} > ?`);
408
+ params.push(after);
384
409
  }
385
- const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
386
- const primaryKey = quoteIdent(table.primaryKey);
387
- let after: SqlValue | undefined;
388
- let applied = 0;
389
- for (;;) {
390
- const boundary = db.query(
391
- `SELECT ${primaryKey} AS boundary FROM ${source}
392
- ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
393
- ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
394
- after === undefined ? [] : [after],
395
- )[0]?.boundary;
396
- const predicates: string[] = [];
397
- const params: SqlValue[] = [];
398
- if (after !== undefined) {
399
- predicates.push(`${primaryKey} > ?`);
400
- params.push(after);
401
- }
402
- if (boundary !== undefined) {
403
- predicates.push(`${primaryKey} <= ?`);
404
- params.push(boundary);
405
- }
410
+ if (boundary !== undefined) {
411
+ predicates.push(`${primaryKey} <= ?`);
412
+ params.push(boundary);
413
+ }
414
+ (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
415
+ if (after === undefined && options.clearFirst)
416
+ deleteScopedRows(db, table, options.effective);
406
417
  db.exec(
407
418
  `INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
408
419
  SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
@@ -411,18 +422,15 @@ export function applySqliteSegment(
411
422
  params,
412
423
  );
413
424
  applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
414
- if (boundary === undefined) break;
415
- options.onProgress?.(applied);
416
- after = boundary;
417
- }
418
- if (applied !== descriptor.rowCount) {
419
- imageInvalid(
420
- `image holds ${applied} rows, descriptor says ${descriptor.rowCount}`,
421
- );
422
- }
425
+ });
423
426
  options.onProgress?.(applied);
424
- return applied;
425
- });
427
+ await yieldToHost();
428
+ if (boundary === undefined || applied === count) break;
429
+ after = boundary;
430
+ }
431
+ if (applied !== count)
432
+ imageInvalid('image applied row count does not match descriptor');
433
+ return applied;
426
434
  });
427
435
  }
428
436
 
@@ -444,6 +452,7 @@ export async function applyRowsSegment(
444
452
  readonly effective: ScopeMap;
445
453
  readonly transaction?: ApplyTransaction;
446
454
  readonly onProgress?: (rowsProcessed: number) => void;
455
+ readonly isCurrent?: () => boolean;
447
456
  },
448
457
  encryption?: EncryptionConfig,
449
458
  ): Promise<number> {
@@ -453,6 +462,7 @@ export async function applyRowsSegment(
453
462
  const blocks: readonly (readonly SegmentRow[])[] =
454
463
  segment.blocks.length > 0 ? segment.blocks : [[]];
455
464
  for (const block of blocks) {
465
+ if (options.isCurrent?.() === false) break;
456
466
  // §5.11: decrypt this block's rows before opening the sync transaction
457
467
  // (WebCrypto is async; the SQLite transaction is not). Decrypt failure
458
468
  // aborts before any write in this block.
@@ -467,6 +477,7 @@ export async function applyRowsSegment(
467
477
  : row.values;
468
478
  rows.push({ values, serverVersion: row.serverVersion });
469
479
  }
480
+ if (options.isCurrent?.() === false) break;
470
481
  const clearThisBlock = first && options.clearFirst;
471
482
  first = false;
472
483
  if (!clearThisBlock && rows.length === 0) continue;
@@ -480,7 +491,22 @@ export async function applyRowsSegment(
480
491
  if (applied % 1024 === 0) options.onProgress?.(applied);
481
492
  }
482
493
  });
494
+ options.onProgress?.(applied);
495
+ await yieldToHost();
483
496
  }
484
497
  options.onProgress?.(applied);
485
498
  return applied;
486
499
  }
500
+
501
+ /** Let worker messages run only after the current SQLite transaction commits. */
502
+ export function yieldToHost(): Promise<void> {
503
+ return new Promise((resolve) => {
504
+ const channel = new MessageChannel();
505
+ channel.port1.onmessage = () => {
506
+ channel.port1.close();
507
+ channel.port2.close();
508
+ resolve();
509
+ };
510
+ channel.port2.postMessage(null);
511
+ });
512
+ }
@@ -69,7 +69,11 @@ export class BunClientDatabase implements ClientDatabase {
69
69
  * §5.3 image import: bun:sqlite attaches files, not buffers, so the
70
70
  * image lands in a private temp file for the duration of the ATTACH.
71
71
  */
72
- withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
72
+ async withSqliteImage<T>(
73
+ bytes: Uint8Array,
74
+ alias: string,
75
+ fn: () => T | Promise<T>,
76
+ ): Promise<T> {
73
77
  assertImageAlias(alias);
74
78
  const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
75
79
  const path = join(dir, 'segment.db');
@@ -77,7 +81,7 @@ export class BunClientDatabase implements ClientDatabase {
77
81
  writeFileSync(path, bytes);
78
82
  this.db.run(`ATTACH DATABASE ? AS ${alias}`, [path]);
79
83
  try {
80
- return fn();
84
+ return await fn();
81
85
  } finally {
82
86
  this.db.run(`DETACH DATABASE ${alias}`);
83
87
  }