@syncular/client 0.15.42 → 0.15.44

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/schema.ts CHANGED
@@ -342,6 +342,22 @@ function createSyncedTable(
342
342
 
343
343
  const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
344
344
 
345
+ /**
346
+ * Create (or migrate) one contentful FTS5 projection for a table plus its
347
+ * synchronizing triggers. Every open drops and recreates the full trigger set
348
+ * (`_bi`, `_ai`, `_ad`, `_au`, and, when the table has a unique index, `_bu`),
349
+ * so existing databases pick up newly added guards. The BEFORE INSERT (`_bi`)
350
+ * and BEFORE UPDATE (`_bu`) guards remove projection rows for entries displaced
351
+ * by `INSERT OR REPLACE` / `UPDATE OR REPLACE` through the primary key or a
352
+ * secondary unique index — cases where SQLite does not reliably fire the AFTER
353
+ * DELETE trigger for the displaced row.
354
+ *
355
+ * Limitation: because a BEFORE trigger runs before SQLite resolves the
356
+ * conflict, an `OR IGNORE` write whose row is dropped still executes these
357
+ * displacement DELETEs, transiently removing the surviving row's projection
358
+ * entry. Syncular's mirror writes never use `OR IGNORE`; see the guard-block
359
+ * comment below for the full rationale.
360
+ */
345
361
  function createFtsProjection(
346
362
  db: ClientDatabase,
347
363
  table: CompiledClientTable,
@@ -371,18 +387,58 @@ function createFtsProjection(
371
387
  ].join(', ');
372
388
  const deleteFor = (value: string) =>
373
389
  `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
390
+ const deleteDisplaced = (select: string) =>
391
+ `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
374
392
 
375
393
  // A clean insert cannot already have a projection row because the source
376
394
  // primary key is unique. Keep clean inserts linear by moving replacement
377
- // cleanup behind an indexed source-table existence check. SQLite does not
378
- // reliably invoke DELETE triggers for the row displaced by REPLACE.
379
- for (const suffix of ['bi', 'ai', 'ad', 'au']) {
395
+ // cleanup behind indexed source-table existence checks. SQLite does not
396
+ // reliably invoke DELETE triggers for rows displaced by REPLACE, and a
397
+ // REPLACE can displace rows through TWO paths: the primary key AND any
398
+ // secondary UNIQUE index (a different-PK row whose unique key matches the
399
+ // incoming row). The BEFORE INSERT guard covers both for `INSERT OR REPLACE`;
400
+ // the mirroring BEFORE UPDATE guard covers `UPDATE OR REPLACE` that pushes a
401
+ // different-PK row out through a unique index (the AFTER UPDATE trigger only
402
+ // knows the updated row's own old/new ids, so it would leave that displaced
403
+ // row's projection entry behind as a ghost hit). Primary-key displacement by
404
+ // `UPDATE OR REPLACE` needs no BEFORE guard: the new pk equals the displaced
405
+ // row's pk, so the AFTER UPDATE delete of the new source id already clears it.
406
+ //
407
+ // Known limitation — `OR IGNORE`: a BEFORE trigger fires before SQLite
408
+ // resolves the conflict, and SQLite exposes no signal for the eventual
409
+ // resolution. So an `INSERT OR IGNORE` / `UPDATE OR IGNORE` whose row is
410
+ // dropped on conflict still runs these displacement DELETEs, removing the
411
+ // SURVIVING row's projection entry (a false negative until that row is next
412
+ // rewritten). Moving cleanup to AFTER triggers would dodge the no-op but
413
+ // reintroduce the REPLACE-doesn't-fire-DELETE gap and a full-projection scan
414
+ // per write, so the guards stay BEFORE. Syncular's own mirror writes use
415
+ // `INSERT … ON CONFLICT (pk) DO UPDATE` (apply.ts), which never takes the
416
+ // IGNORE path; only hand-written `OR IGNORE` against a mirror table is exposed.
417
+ for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
380
418
  db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
381
419
  }
382
420
  const replacementExists = `EXISTS (SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = new.${quoteIdent(table.primaryKey)})`;
421
+ // Per secondary UNIQUE index: the different-PK rows about to be displaced
422
+ // via that unique key. `=` makes NULL unique values match nothing, which is
423
+ // exactly SQLite's unique-index semantics (NULLs never conflict).
424
+ const uniqueIndexes = table.indexes.filter((spec) => spec.unique);
425
+ const displacedByUnique = uniqueIndexes.map((spec) => {
426
+ const match = spec.columns
427
+ .map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
428
+ .join(' AND ');
429
+ return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
430
+ });
431
+ const insertGuardCondition = [
432
+ replacementExists,
433
+ ...displacedByUnique.map((select) => `EXISTS (${select})`),
434
+ ].join(' OR ');
435
+ const insertGuardBody = [
436
+ deleteFor(newSourceId),
437
+ ...displacedByUnique.map(deleteDisplaced),
438
+ ].join('; ');
383
439
 
384
440
  db.exec(
385
- `CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${replacementExists} BEGIN ${deleteFor(newSourceId)}; END`,
441
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
386
442
  );
387
443
  db.exec(
388
444
  `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
@@ -393,6 +449,19 @@ function createFtsProjection(
393
449
  db.exec(
394
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`,
395
451
  );
452
+ // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
453
+ // different-PK row about to be displaced through a secondary unique index by
454
+ // the new values. Only meaningful when the table has a unique index; with
455
+ // none, no update can displace a foreign row, so the trigger is omitted.
456
+ if (displacedByUnique.length > 0) {
457
+ const updateGuardCondition = displacedByUnique
458
+ .map((select) => `EXISTS (${select})`)
459
+ .join(' OR ');
460
+ const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
461
+ db.exec(
462
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`,
463
+ );
464
+ }
396
465
 
397
466
  if (!existed) {
398
467
  db.exec(
@@ -42,6 +42,7 @@ import {
42
42
  type WorkerErrorShape,
43
43
  type WorkerInitConfig,
44
44
  type WorkerInitResult,
45
+ type WorkerMethod,
45
46
  type WorkerToMainMessage,
46
47
  } from './worker-protocol';
47
48
 
@@ -491,9 +492,22 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
491
492
  },
492
493
  };
493
494
 
495
+ // Local purge/rebootstrap rewrite the same durable state an in-flight
496
+ // sync round captured at send time; running their RPCs on the sync chain
497
+ // orders them against RPC- and auto-driven rounds (the client core's
498
+ // reset fence covers hosts that call the core directly).
499
+ const syncChainMethods: ReadonlySet<WorkerMethod> = new Set<WorkerMethod>([
500
+ 'purgeLocalData',
501
+ 'rebootstrapLocalData',
502
+ ]);
503
+
494
504
  async function dispatch(message: WorkerCallMessage): Promise<unknown> {
495
505
  const method = api[message.method] as (...args: unknown[]) => unknown;
496
- return await method.apply(api, message.args as unknown[]);
506
+ const invoke = () => method.apply(api, message.args as unknown[]);
507
+ if (syncChainMethods.has(message.method)) {
508
+ return await serializedSync(async () => invoke());
509
+ }
510
+ return await invoke();
497
511
  }
498
512
 
499
513
  function run(
@@ -334,6 +334,17 @@ export class SyncClientHandle {
334
334
  });
335
335
  }
336
336
 
337
+ /**
338
+ * @internal — whether `close()` has run.
339
+ *
340
+ * A follower keeps a blocking `acquire` outstanding for the whole time it is a
341
+ * follower, so its promotion can fire long after the application has discarded
342
+ * the handle. The promotion path consults this before opening anything.
343
+ */
344
+ get __isClosed(): boolean {
345
+ return this.#closed;
346
+ }
347
+
337
348
  /** @internal — swap this handle from follower to leader (promotion). */
338
349
  __becomeLeader(core: LeaderCore): void {
339
350
  this.#follower?.close();
@@ -1020,6 +1031,14 @@ async function bootFollower(
1020
1031
  await lease.release();
1021
1032
  return;
1022
1033
  }
1034
+ if (handle.__isClosed) {
1035
+ // The application discarded this handle while it was queued for
1036
+ // leadership. Promoting now would open a database nobody is holding and
1037
+ // then keep the lock forever, so no other tab could ever take over.
1038
+ // Release instead, and let the next waiter have it.
1039
+ await lease.release();
1040
+ return;
1041
+ }
1023
1042
  // The follower saw the departing leader's epoch; the new leader must
1024
1043
  // strictly exceed it so stale replies/events are discarded everywhere.
1025
1044
  const nextEpoch = follower.maxEpochSeen + 1;
@@ -1058,6 +1077,13 @@ async function bootFollower(
1058
1077
  }
1059
1078
  : {}),
1060
1079
  });
1080
+ if (handle.__isClosed) {
1081
+ // Closed while the worker was starting. `close()` already ran and found
1082
+ // no core to shut down, so this one would leak: tear it down here rather
1083
+ // than hand it to a discarded handle. `close(true)` releases the lease.
1084
+ await core.close(true);
1085
+ return;
1086
+ }
1061
1087
  handle.__becomeLeader(core);
1062
1088
  } catch {
1063
1089
  // Promotion failed to spawn a worker — release so the next tab tries.