@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.
package/src/client.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  deleteLocalRow,
45
45
  deleteScopedRows,
46
46
  evictScopedRows,
47
+ yieldToHost,
47
48
  upsertLocalRow,
48
49
  } from './apply';
49
50
  import {
@@ -286,8 +287,8 @@ export interface SyncClientLimits {
286
287
  readonly maxSnapshotPages?: number;
287
288
  /**
288
289
  * §4.2 accept bitmask; defaults to inline + external rows (0b0011)
289
- * plus sqlite images (bit 2) when the database backend implements
290
- * `withSqliteImage` and a segment downloader is configured (§5.3).
290
+ * plus SQLite images and signed URLs when supported. Both image chunks
291
+ * and rows blocks commit before yielding to local reads (§7.7).
291
292
  */
292
293
  readonly accept?: number;
293
294
  /**
@@ -748,6 +749,7 @@ export class SyncClient {
748
749
  const startupWork =
749
750
  this.#schemaFloor === undefined &&
750
751
  (countOutbox(this.#db) > 0 ||
752
+ loadPendingEvictions(this.#db).length > 0 ||
751
753
  subscriptions.some((sub) => sub.status === 'active'));
752
754
  if (startupWork && this.#securityLifecycle === 'active') {
753
755
  this.#needsPull = true;
@@ -1820,23 +1822,17 @@ export class SyncClient {
1820
1822
  socket.send(encodePresencePublish(scopeKey, doc));
1821
1823
  }
1822
1824
 
1823
- /**
1824
- * §4.2 accept mask: the configured override, or the rows baseline plus
1825
- * bit 2 when the backend can import sqlite images (§5.3) and a segment
1826
- * downloader exists (sqlite segments are never inline, §5.7), plus
1827
- * bit 3 when the downloader exposes a direct URL fetch (§5.4
1828
- * capability negotiation).
1829
- */
1825
+ /** Advertise supported segment formats; all imports yield between commits. */
1830
1826
  #acceptMask(): number {
1831
1827
  const configured = this.#config.limits?.accept;
1832
1828
  if (configured !== undefined) return configured;
1833
1829
  const segments = this.#config.segments;
1834
- const sqliteCapable =
1835
- typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
1836
1830
  const urlCapable = typeof segments?.fetchUrl === 'function';
1837
1831
  return (
1838
1832
  ACCEPT_ROWS_BASELINE |
1839
- (sqliteCapable ? ACCEPT_SQLITE : 0) |
1833
+ (typeof this.#db.withSqliteImage === 'function' && segments !== undefined
1834
+ ? ACCEPT_SQLITE
1835
+ : 0) |
1840
1836
  (urlCapable ? ACCEPT_SIGNED_URLS : 0)
1841
1837
  );
1842
1838
  }
@@ -1909,8 +1905,8 @@ export class SyncClient {
1909
1905
  * Set the live window units for a base (§4.8): a value-sharded family of
1910
1906
  * subscriptions, one per unit. Computes the diff against the registry —
1911
1907
  * added units get fresh subscriptions (image-lane bootstrap on the next
1912
- * sync); removed units are unsubscribed and evicted, fused in one local
1913
- * transaction (E1–E4). Idempotent: calling with the same units is a
1908
+ * sync); removed units are unsubscribed with their first eviction chunk
1909
+ * in one local transaction (E1–E4). Idempotent: calling with the same units is a
1914
1910
  * no-op. Re-entry (a unit removed then re-added) cancels any deferred
1915
1911
  * eviction and fresh-bootstraps.
1916
1912
  *
@@ -1975,7 +1971,7 @@ export class SyncClient {
1975
1971
  // Shrink: units live but not wanted → unsubscribe fused with eviction.
1976
1972
  for (const { unit, subId } of live) {
1977
1973
  if (wanted.has(unit)) continue;
1978
- this.#evictUnit(baseKey, base, unit, subId);
1974
+ await this.#evictUnit(baseKey, base, unit, subId);
1979
1975
  changed = true;
1980
1976
  }
1981
1977
  });
@@ -2018,8 +2014,8 @@ export class SyncClient {
2018
2014
  }
2019
2015
 
2020
2016
  /**
2021
- * §4.8 E1–E4: evict one departing unit, fused with its unsubscription in
2022
- * one transaction. Deletes the unit's rows EXCEPT those pinned by a
2017
+ * §4.8 E1–E4: evict one departing unit in committed chunks, fusing
2018
+ * its first chunk with unsubscription. Deletes the unit's rows EXCEPT those pinned by a
2023
2019
  * pending outbox commit (E1); if any pin remains, records a deferred
2024
2020
  * eviction retried on the next outbox drain. Discards the subscription's
2025
2021
  * cursor/resume/effective-echo (E3) and its version state with the rows
@@ -2027,24 +2023,26 @@ export class SyncClient {
2027
2023
  * with no local scope-column mapping, surfaces a configuration error and
2028
2024
  * evicts nothing (§4.8/§3.3).
2029
2025
  */
2030
- #evictUnit(
2026
+ async #evictUnit(
2031
2027
  baseKey: string,
2032
2028
  base: WindowBase,
2033
2029
  unit: string,
2034
2030
  subId: string,
2035
- ): void {
2031
+ ): Promise<void> {
2036
2032
  const table = this.#table(base.table);
2037
2033
  const sub = getSubscription(this.#db, subId);
2038
2034
  // The rows a unit holds live under its LAST effective scopes if it ever
2039
2035
  // synced; before first sync, the requested unit scopes are the match.
2040
2036
  const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
2041
2037
  const pinned = this.#pinnedRowIds(base.table);
2038
+ let remaining = false;
2042
2039
  this.#applyBatch((batch) => {
2043
2040
  this.#db.transaction(() => {
2044
- const deferred = evictScopedRows(this.#db, table, effective, pinned);
2041
+ const result = evictScopedRows(this.#db, table, effective, pinned);
2042
+ remaining = result.remaining;
2045
2043
  deleteWindowUnit(this.#db, baseKey, unit);
2046
2044
  deleteSubscription(this.#db, subId);
2047
- if (deferred) {
2045
+ if (result.deferred) {
2048
2046
  savePendingEviction(this.#db, subId, base.table, effective);
2049
2047
  } else {
2050
2048
  deletePendingEviction(this.#db, subId);
@@ -2055,6 +2053,10 @@ export class SyncClient {
2055
2053
  this.#needsPull = true;
2056
2054
  batch.status();
2057
2055
  });
2056
+ if (remaining) {
2057
+ await yieldToHost();
2058
+ await this.#drainPendingEvictions();
2059
+ }
2058
2060
  }
2059
2061
 
2060
2062
  /**
@@ -2062,7 +2064,7 @@ export class SyncClient {
2062
2064
  * unit's rows are removed once no pending commit references them; a unit
2063
2065
  * that re-entered the window in the meantime has no pending record left.
2064
2066
  */
2065
- #drainPendingEvictions(): void {
2067
+ async #drainPendingEvictions(): Promise<void> {
2066
2068
  const pending = loadPendingEvictions(this.#db);
2067
2069
  if (pending.length === 0) return;
2068
2070
  for (const entry of pending) {
@@ -2071,15 +2073,30 @@ export class SyncClient {
2071
2073
  deletePendingEviction(this.#db, entry.subId);
2072
2074
  continue;
2073
2075
  }
2074
- const pinned = this.#pinnedRowIds(entry.table);
2075
- this.#applyBatch((batch) => {
2076
- let deferred = false;
2077
- this.#db.transaction(() => {
2078
- deferred = evictScopedRows(this.#db, table, entry.effective, pinned);
2079
- if (!deferred) deletePendingEviction(this.#db, entry.subId);
2076
+ let remaining: boolean;
2077
+ do {
2078
+ if (
2079
+ !loadPendingEvictions(this.#db).some(
2080
+ (item) => item.subId === entry.subId,
2081
+ )
2082
+ )
2083
+ break;
2084
+ // Local writes may add pins while the worker yields between chunks.
2085
+ const pinned = this.#pinnedRowIds(entry.table);
2086
+ remaining = false;
2087
+ this.#applyBatch((batch) => {
2088
+ const result = evictScopedRows(
2089
+ this.#db,
2090
+ table,
2091
+ entry.effective,
2092
+ pinned,
2093
+ );
2094
+ remaining = result.remaining;
2095
+ if (!result.deferred) deletePendingEviction(this.#db, entry.subId);
2096
+ batch.scopeMap(table, entry.effective);
2080
2097
  });
2081
- batch.scopeMap(table, entry.effective);
2082
- });
2098
+ if (remaining) await yieldToHost();
2099
+ } while (remaining);
2083
2100
  }
2084
2101
  }
2085
2102
 
@@ -2753,6 +2770,7 @@ export class SyncClient {
2753
2770
  // survive it — the reference server keeps no replay buffer (§8.2).
2754
2771
  this.#setSyncNeeded(false);
2755
2772
  try {
2773
+ await this.#drainPendingEvictions();
2756
2774
  const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2757
2775
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
2758
2776
  // them, so the server-side existence check (§6.6) passes.
@@ -2830,7 +2848,7 @@ export class SyncClient {
2830
2848
  );
2831
2849
  // §4.8 E1: the push half may have drained commits that pinned rows of
2832
2850
  // a shrunk window unit — retry any deferred evictions now.
2833
- this.#drainPendingEvictions();
2851
+ await this.#drainPendingEvictions();
2834
2852
  this.#progress.update(
2835
2853
  summary.failed.length > 0
2836
2854
  ? { state: 'failed', errorCode: 'sync.scope_revoked' }
@@ -2879,10 +2897,27 @@ export class SyncClient {
2879
2897
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
2880
2898
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
2881
2899
  */
2882
- async syncUntilIdle(maxRounds = 20): Promise<SyncSummary> {
2900
+ async syncUntilIdle(maxRounds?: number): Promise<SyncSummary> {
2901
+ const budget = maxRounds ?? 20;
2883
2902
  let last: SyncSummary | undefined;
2884
- for (let round = 0; round < maxRounds; round++) {
2903
+ for (let round = 0; round < budget; round++) {
2904
+ const before = JSON.stringify(
2905
+ loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
2906
+ );
2885
2907
  last = await this.sync();
2908
+ if (
2909
+ maxRounds === undefined &&
2910
+ last.segmentRowsApplied > 0 &&
2911
+ last.bootstrapping.length > 0 &&
2912
+ before !==
2913
+ JSON.stringify(
2914
+ loadSubscriptions(this.#db).map((sub) => [
2915
+ sub.id,
2916
+ sub.bootstrapState,
2917
+ ]),
2918
+ )
2919
+ )
2920
+ round = -1;
2886
2921
  if (last.schemaFloor !== undefined) return last;
2887
2922
  if (
2888
2923
  last.commitsApplied === 0 &&
@@ -2897,7 +2932,7 @@ export class SyncClient {
2897
2932
  }
2898
2933
  throw new ClientSyncError(
2899
2934
  'sync.invalid_request',
2900
- `sync did not reach idle within ${maxRounds} rounds`,
2935
+ 'sync did not reach idle within the round budget',
2901
2936
  );
2902
2937
  }
2903
2938
 
@@ -3428,6 +3463,7 @@ export class SyncClient {
3428
3463
  segment,
3429
3464
  {
3430
3465
  clearFirst,
3466
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
3431
3467
  onProgress: (rowsProcessed) =>
3432
3468
  this.#progress.update({ rowsProcessed }),
3433
3469
  effective,
@@ -3440,7 +3476,9 @@ export class SyncClient {
3440
3476
  ) {
3441
3477
  batch.table(table.name);
3442
3478
  }
3443
- return fn();
3479
+ const result = fn();
3480
+ this.#replayOutbox();
3481
+ return result;
3444
3482
  }),
3445
3483
  },
3446
3484
  this.#encryption,
@@ -3499,6 +3537,7 @@ export class SyncClient {
3499
3537
  },
3500
3538
  {
3501
3539
  clearFirst,
3540
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
3502
3541
  onProgress: (rowsProcessed) =>
3503
3542
  this.#progress.update({ rowsProcessed }),
3504
3543
  effective,
@@ -3511,7 +3550,9 @@ export class SyncClient {
3511
3550
  ) {
3512
3551
  batch.table(table.name);
3513
3552
  }
3514
- return fn();
3553
+ const result = fn();
3554
+ this.#replayOutbox();
3555
+ return result;
3515
3556
  }),
3516
3557
  },
3517
3558
  ),
@@ -3530,6 +3571,7 @@ export class SyncClient {
3530
3571
  segment,
3531
3572
  {
3532
3573
  clearFirst,
3574
+ isCurrent: () => this.#localResetEpoch === resetEpoch,
3533
3575
  onProgress: (rowsProcessed) =>
3534
3576
  this.#progress.update({ rowsProcessed }),
3535
3577
  effective,
@@ -3542,7 +3584,9 @@ export class SyncClient {
3542
3584
  ) {
3543
3585
  batch.table(table.name);
3544
3586
  }
3545
- return fn();
3587
+ const result = fn();
3588
+ this.#replayOutbox();
3589
+ return result;
3546
3590
  }),
3547
3591
  },
3548
3592
  this.#encryption,
package/src/database.ts CHANGED
@@ -22,11 +22,16 @@ export interface ClientDatabase {
22
22
  /**
23
23
  * Optional sqlite-image capability (SPEC §5.3): expose `bytes` as an
24
24
  * attached read-only database schema named `alias` for the duration of
25
- * `fn`, then detach. Presence of this method is what makes the client
25
+ * `fn`, awaiting its result before detaching. No transaction spans its awaits.
26
+ * Presence of this method is what makes the client
26
27
  * advertise `accept` bit 2 (§4.2). MUST be called outside any open
27
28
  * transaction (SQLite cannot ATTACH inside one); `fn` may open its own.
28
29
  */
29
- withSqliteImage?<T>(bytes: Uint8Array, alias: string, fn: () => T): T;
30
+ withSqliteImage?<T>(
31
+ bytes: Uint8Array,
32
+ alias: string,
33
+ fn: () => T | Promise<T>,
34
+ ): Promise<T>;
30
35
  close(): void;
31
36
  }
32
37
 
@@ -63,7 +63,11 @@ export class NodeClientDatabase implements ClientDatabase {
63
63
  }
64
64
 
65
65
  /** §5.3 image import through a private file attached for one callback. */
66
- withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
66
+ async withSqliteImage<T>(
67
+ bytes: Uint8Array,
68
+ alias: string,
69
+ fn: () => T | Promise<T>,
70
+ ): Promise<T> {
67
71
  assertImageAlias(alias);
68
72
  const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
69
73
  const path = join(dir, 'segment.db');
@@ -71,7 +75,7 @@ export class NodeClientDatabase implements ClientDatabase {
71
75
  writeFileSync(path, bytes);
72
76
  this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
73
77
  try {
74
- return fn();
78
+ return await fn();
75
79
  } finally {
76
80
  this.db.prepare(`DETACH DATABASE ${alias}`).run();
77
81
  }
@@ -359,7 +359,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
359
359
  #running = false;
360
360
  #requested = false;
361
361
  #desiredRevision = 0n;
362
- #claimReady: Promise<void> = Promise.resolve();
362
+ #claimError: Error | undefined;
363
+ #claimPending = false;
363
364
  #offStatus: (() => void) | undefined;
364
365
 
365
366
  constructor(
@@ -397,10 +398,25 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
397
398
  ),
398
399
  );
399
400
  }
400
- this.#claimReady = Promise.all(claims).then(() => undefined);
401
- // A render can lose its last subscriber before the read loop starts.
402
- // The loop still observes the original rejection while it owns the claim.
403
- void this.#claimReady.catch(() => undefined);
401
+ const generation = this.#generation;
402
+ this.#claimPending = claims.length > 0;
403
+ if (claims.length > 0)
404
+ void Promise.all(claims).then(
405
+ () => {
406
+ if (generation !== this.#generation || this.#listeners.size === 0)
407
+ return;
408
+ this.#claimPending = false;
409
+ if (!this.#running && this.#state.phase !== 'ready')
410
+ this.#requestRead();
411
+ },
412
+ (error: unknown) => {
413
+ if (generation !== this.#generation || this.#listeners.size === 0)
414
+ return;
415
+ this.#claimPending = false;
416
+ this.#claimError = errorOf(error);
417
+ this.#requestRead();
418
+ },
419
+ );
404
420
  }
405
421
  this.#requestRead();
406
422
  }
@@ -426,7 +442,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
426
442
  this.#running = false;
427
443
  this.#requested = false;
428
444
  this.#desiredRevision = 0n;
429
- this.#claimReady = Promise.resolve();
445
+ this.#claimError = undefined;
446
+ this.#claimPending = false;
430
447
  this.#state = {
431
448
  rows: [],
432
449
  phase: 'loading',
@@ -520,9 +537,11 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
520
537
  do {
521
538
  this.#requested = false;
522
539
  if (this.store.availabilitySnapshot().state === 'blocked') break;
523
- await this.#claimReady;
540
+
524
541
  if (generation !== this.#generation || this.#listeners.size === 0)
525
542
  return;
543
+ if (this.#claimError !== undefined) throw this.#claimError;
544
+ const claimWasPending = this.#claimPending;
526
545
  const snapshot = await this.store.client.querySnapshot<
527
546
  Record<string, SqlValue>
528
547
  >({
@@ -536,6 +555,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
536
555
  });
537
556
  if (generation !== this.#generation || this.#listeners.size === 0)
538
557
  return;
558
+ if (this.#claimError !== undefined) throw this.#claimError;
539
559
  const availability = this.store.availabilitySnapshot();
540
560
  if (availability.state === 'blocked') {
541
561
  this.#publish({
@@ -550,6 +570,12 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
550
570
  this.#requested = true;
551
571
  continue;
552
572
  }
573
+ if (
574
+ !snapshot.coverage.complete &&
575
+ claimWasPending &&
576
+ !this.#claimPending
577
+ )
578
+ this.#requested = true;
553
579
  const mappedRows =
554
580
  this.spec.mapRow === undefined
555
581
  ? (snapshot.rows as readonly Row[])
@@ -625,6 +651,8 @@ interface WindowClaimGroup {
625
651
  readonly base: WindowBase;
626
652
  readonly claims: Map<symbol, ReadonlySet<string>>;
627
653
  appliedKey: string;
654
+ appliedUnits: ReadonlySet<string>;
655
+ inFlightUnits?: ReadonlySet<string> | undefined;
628
656
  scheduled: boolean;
629
657
  running: boolean;
630
658
  requested: boolean;
@@ -889,6 +917,7 @@ export class ReactiveClientStore {
889
917
  base,
890
918
  claims: new Map(),
891
919
  appliedKey: '',
920
+ appliedUnits: new Set(),
892
921
  scheduled: false,
893
922
  running: false,
894
923
  requested: false,
@@ -897,6 +926,17 @@ export class ReactiveClientStore {
897
926
  this.#windowClaims.set(key, group);
898
927
  }
899
928
  group.claims.set(owner, new Set(units));
929
+ const alreadyHeld =
930
+ units.length > 0 &&
931
+ units.every(
932
+ (unit) =>
933
+ group.appliedUnits.has(unit) &&
934
+ (group.inFlightUnits === undefined || group.inFlightUnits.has(unit)),
935
+ );
936
+ if (alreadyHeld) {
937
+ this.#scheduleWindow(group);
938
+ return Promise.resolve();
939
+ }
900
940
  const result = new Promise<void>((resolve, reject) => {
901
941
  group?.waiters.push({ resolve, reject });
902
942
  });
@@ -932,13 +972,23 @@ export class ReactiveClientStore {
932
972
  ].sort();
933
973
  const key = canonicalValue(units);
934
974
  if (key !== group.appliedKey) {
935
- await this.client.setWindow(group.base, units);
975
+ group.inFlightUnits = new Set(units);
976
+ try {
977
+ await this.client.setWindow(group.base, units);
978
+ } finally {
979
+ group.inFlightUnits = undefined;
980
+ }
936
981
  if (this.#windowClaims.get(baseKey) !== group) return;
937
982
  group.appliedKey = key;
983
+ group.appliedUnits = new Set(units);
938
984
  }
939
985
  }
940
986
  for (const waiter of group.waiters.splice(0)) waiter.resolve();
941
987
  } catch (error) {
988
+ // A failed window edit may already have committed its first eviction chunk.
989
+ // Reconcile through the core before acknowledging any later claim.
990
+ group.appliedKey = '';
991
+ group.appliedUnits = new Set();
942
992
  for (const waiter of group.waiters.splice(0)) waiter.reject(error);
943
993
  } finally {
944
994
  group.running = false;
package/src/schema.ts CHANGED
@@ -374,6 +374,14 @@ function createFtsProjection(
374
374
  `CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`,
375
375
  );
376
376
 
377
+ const mapping = quoteIdent(`_syncular_fts_${index.name}`);
378
+ const mapped =
379
+ db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
380
+ `_syncular_fts_${index.name}`,
381
+ ]).length > 0;
382
+ db.exec(
383
+ `CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`,
384
+ );
377
385
  const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
378
386
  const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
379
387
  const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
@@ -386,9 +394,10 @@ function createFtsProjection(
386
394
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
387
395
  ].join(', ');
388
396
  const deleteFor = (value: string) =>
389
- `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
397
+ `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
390
398
  const deleteDisplaced = (select: string) =>
391
- `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
399
+ `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})`;
400
+ 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})`;
392
401
 
393
402
  // A clean insert cannot already have a projection row because the source
394
403
  // primary key is unique. Keep clean inserts linear by moving replacement
@@ -441,13 +450,13 @@ function createFtsProjection(
441
450
  `CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
442
451
  );
443
452
  db.exec(
444
- `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
453
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`,
445
454
  );
446
455
  db.exec(
447
456
  `CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`,
448
457
  );
449
458
  db.exec(
450
- `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`,
459
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`,
451
460
  );
452
461
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
453
462
  // different-PK row about to be displaced through a secondary unique index by
@@ -468,6 +477,12 @@ function createFtsProjection(
468
477
  `INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
469
478
  );
470
479
  }
480
+ if (!mapped || !existed) {
481
+ db.exec(`DELETE FROM ${mapping}`);
482
+ db.exec(
483
+ `INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
484
+ );
485
+ }
471
486
  }
472
487
 
473
488
  /**
@@ -602,6 +617,9 @@ export function dropAndRecreateSyncedTables(
602
617
  AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
603
618
  );
604
619
  for (const row of virtualTables) {
620
+ db.exec(
621
+ `DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`,
622
+ );
605
623
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
606
624
  }
607
625
  const existing = db.query(
@@ -151,9 +151,13 @@ class WasmClientDatabase implements ClientDatabase {
151
151
  * §5.3 image import at near-file-copy speed: attach an empty in-memory
152
152
  * schema, then `sqlite3_deserialize` the image bytes into it (the
153
153
  * documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
154
- * row shuttling before the single INSERT…SELECT the caller runs).
154
+ * row shuttling before the chunked INSERT…SELECT statements the caller runs).
155
155
  */
156
- withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
156
+ async withSqliteImage<T>(
157
+ bytes: Uint8Array,
158
+ alias: string,
159
+ fn: () => T | Promise<T>,
160
+ ): Promise<T> {
157
161
  assertImageAlias(alias);
158
162
  const pointer = this.#db.pointer;
159
163
  if (pointer === undefined) {
@@ -182,7 +186,7 @@ class WasmClientDatabase implements ClientDatabase {
182
186
  `sqlite3_deserialize failed with code ${rc} (§5.3)`,
183
187
  );
184
188
  }
185
- return fn();
189
+ return await fn();
186
190
  } finally {
187
191
  this.exec(`DETACH ${alias}`);
188
192
  }