@syncular/server 0.37.0 → 0.39.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.
@@ -2,7 +2,7 @@ import { retainRollbackFailure, rollbackAfterError } from './storage-errors';
2
2
  import { validateCommitPruneQuery } from './prune';
3
3
  import { StorageQueryError } from './storage-errors';
4
4
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
5
- import { serveGateRefusal } from './storage';
5
+ import { PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal } from './storage';
6
6
  import { serveNotReadyError } from './readiness';
7
7
  /**
8
8
  * SQLite server storage over the shared synchronous driver.
@@ -45,6 +45,9 @@ import {
45
45
  type StoredColumnLayout,
46
46
  scanRowPageSql,
47
47
  schemaDdl,
48
+ SQLITE_TABLE_INDEXES_SQL,
49
+ type SqliteTableIndexRow,
50
+ sqliteIndexSignatures,
48
51
  selectRowScopesSql,
49
52
  selectRowSql,
50
53
  selectRowsForRewriteSql,
@@ -653,10 +656,8 @@ END`);
653
656
  // rewrite stored rows (payload re-encode for layout changes, and/or
654
657
  // projection backfill for flipped-on materialization). One
655
658
  // transaction: a failed bump leaves no half-state.
656
- const layouts = parseLayouts(marker?.layouts);
657
- const retiredTables = retiredTableNames(schema, layouts);
658
659
  const existing = new Map<string, ReadonlySet<string>>();
659
- const existingIndexes = new Map<string, ReadonlySet<string>>();
660
+ const existingIndexes = new Map<string, ReadonlyMap<string, string>>();
660
661
  for (const table of schema.tables.values()) {
661
662
  const escapedTableName = table.name.replaceAll('"', '""');
662
663
  const columns = this.db
@@ -666,15 +667,19 @@ END`);
666
667
  .all();
667
668
  if (columns.length > 0) {
668
669
  existing.set(table.name, new Set(columns.map((c) => c.name)));
669
- const indexes = this.db
670
- .query<{ name: string; origin: string }, []>(
671
- `PRAGMA index_list("${escapedTableName}")`,
672
- )
673
- .all()
674
- .filter((index) => index.origin === 'c');
675
670
  existingIndexes.set(
676
671
  table.name,
677
- new Set(indexes.map((index) => index.name)),
672
+ new Map(
673
+ Object.entries(
674
+ sqliteIndexSignatures(
675
+ this.db
676
+ .query<SqliteTableIndexRow, [string]>(
677
+ SQLITE_TABLE_INDEXES_SQL,
678
+ )
679
+ .all(table.name),
680
+ ),
681
+ ),
682
+ ),
678
683
  );
679
684
  }
680
685
  }
@@ -686,6 +691,25 @@ END`);
686
691
  await this.#serializeWrite(() => {
687
692
  this.db.exec('BEGIN IMMEDIATE');
688
693
  try {
694
+ // Another process sharing the database may have bumped between the
695
+ // marker read above and the writer lock; the locked marker decides
696
+ // (SPEC "Schema upgrade writer fence").
697
+ const locked = this.db
698
+ .query<{ schema_version: number; layouts: string }, []>(
699
+ 'SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1',
700
+ )
701
+ .get();
702
+ if (locked !== null && locked.schema_version > schema.version) {
703
+ throw new Error(
704
+ `stored schema version ${locked.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
705
+ );
706
+ }
707
+ if (locked !== null && locked.schema_version === schema.version) {
708
+ this.db.exec('COMMIT');
709
+ return;
710
+ }
711
+ const layouts = parseLayouts(locked?.layouts);
712
+ const retiredTables = retiredTableNames(schema, layouts);
689
713
  for (const tableName of retiredTables) {
690
714
  this.db
691
715
  .query('DELETE FROM sync_row_scopes WHERE tbl=?')
@@ -763,28 +787,34 @@ END`);
763
787
  if (initialLogEpoch.length === 0) {
764
788
  throw new Error('initial log epoch must be non-empty');
765
789
  }
766
- this.db
767
- .query(
768
- `INSERT INTO sync_partition_registry(
769
- partition, log_epoch, last_authenticated_at_ms
770
- ) VALUES (?,?,?)
771
- ON CONFLICT(partition) DO UPDATE SET
772
- last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
773
- )
774
- .run(partition, initialLogEpoch, authenticatedAtMs);
775
- const row = this.db
776
- .query<
777
- {
778
- log_epoch: string;
779
- epoch_required: number;
780
- last_authenticated_at_ms: number;
781
- },
782
- [string]
783
- >(
784
- `SELECT log_epoch, epoch_required, last_authenticated_at_ms
785
- FROM sync_partition_registry WHERE partition=?`,
786
- )
787
- .get(partition);
790
+ const select = this.db.query<
791
+ {
792
+ log_epoch: string;
793
+ epoch_required: number;
794
+ last_authenticated_at_ms: number;
795
+ },
796
+ [string]
797
+ >(
798
+ `SELECT log_epoch, epoch_required, last_authenticated_at_ms
799
+ FROM sync_partition_registry WHERE partition=?`,
800
+ );
801
+ let row = select.get(partition);
802
+ if (
803
+ row === null ||
804
+ authenticatedAtMs - row.last_authenticated_at_ms >=
805
+ PARTITION_TOUCH_INTERVAL_MS
806
+ ) {
807
+ this.db
808
+ .query(
809
+ `INSERT INTO sync_partition_registry(
810
+ partition, log_epoch, last_authenticated_at_ms
811
+ ) VALUES (?,?,?)
812
+ ON CONFLICT(partition) DO UPDATE SET
813
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
814
+ )
815
+ .run(partition, initialLogEpoch, authenticatedAtMs);
816
+ row = select.get(partition);
817
+ }
788
818
  if (row === null)
789
819
  throw new Error('partition registry write did not persist');
790
820
  return {
@@ -867,7 +897,7 @@ END`);
867
897
  oldLayout: readonly StoredColumnLayout[] | undefined,
868
898
  ): void {
869
899
  const select = selectRowsForRewriteSql(table, 'sqlite');
870
- const update = this.db.query(rewriteRowSql(table, 'sqlite'));
900
+ const update = this.db.query(rewriteRowSql(table));
871
901
  const BATCH = 500;
872
902
  let afterPartition = '';
873
903
  let afterRowId = '';
package/src/storage.ts CHANGED
@@ -358,6 +358,9 @@ export interface ClientCursorInfo {
358
358
  }
359
359
 
360
360
  /** Durable partition identity refreshed after host authentication (§2.1). */
361
+ /** Longest age of `lastAuthenticatedAtMs` that `touchPartition` leaves unwritten. */
362
+ export const PARTITION_TOUCH_INTERVAL_MS = 60_000;
363
+
361
364
  export interface PartitionRegistryEntry {
362
365
  readonly partition: string;
363
366
  readonly logEpoch: string;
@@ -651,7 +654,12 @@ export interface ServerStorage {
651
654
  checkpoints?: readonly CheckpointDeclaration[],
652
655
  ): Promise<void>;
653
656
 
654
- /** Create or refresh the authenticated partition registry row (§2.1). */
657
+ /**
658
+ * Create or refresh the authenticated partition registry row (§2.1). A
659
+ * row whose `lastAuthenticatedAtMs` is less than
660
+ * {@link PARTITION_TOUCH_INTERVAL_MS} behind `authenticatedAtMs` is read
661
+ * back without a write, so a request that only reads takes no write lock.
662
+ */
655
663
  touchPartition(
656
664
  partition: string,
657
665
  authenticatedAtMs: number,