@syncular/server 0.16.1 → 0.17.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.
@@ -45,7 +45,7 @@ import { StorageQueryError } from './storage-errors.js';
45
45
  import { decodeRow } from '@syncular/core';
46
46
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
47
47
  import { syncError } from './errors.js';
48
- import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
48
+ import { assertAppendOnlyMigration, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
49
49
  import { matchesEffective } from './scopes.js';
50
50
  import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
51
51
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
@@ -333,8 +333,14 @@ class D1Transaction {
333
333
  row,
334
334
  });
335
335
  const p = this.#partition;
336
+ this.#buffer_(deleteSqliteRowScopesSql(compiled), [
337
+ p,
338
+ table,
339
+ row.rowId,
340
+ p,
341
+ row.rowId,
342
+ ]);
336
343
  this.#buffer_(upsertSql(compiled, 'sqlite'), upsertValues(compiled, p, row, 'sqlite'));
337
- this.#buffer_('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?', [p, table, row.rowId]);
338
344
  for (const [variable, value] of Object.entries(row.scopes)) {
339
345
  this.#buffer_('INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)', [p, table, variable, value, row.rowId]);
340
346
  }
@@ -343,11 +349,15 @@ class D1Transaction {
343
349
  this.#assertOpen();
344
350
  this.#pending.set(_a.#key(table, rowId), { kind: 'deleted' });
345
351
  const p = this.#partition;
346
- this.#buffer_(deleteRowSql(this.#resolveTable(table), 'sqlite'), [
352
+ const compiled = this.#resolveTable(table);
353
+ this.#buffer_(deleteSqliteRowScopesSql(compiled), [
354
+ p,
355
+ table,
356
+ rowId,
347
357
  p,
348
358
  rowId,
349
359
  ]);
350
- this.#buffer_('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?', [p, table, rowId]);
360
+ this.#buffer_(deleteRowSql(compiled, 'sqlite'), [p, rowId]);
351
361
  // §5.9.4: a deleted row references no blobs.
352
362
  this.#buffer_('DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?', [p, table, rowId]);
353
363
  }
@@ -497,79 +507,370 @@ export class D1ServerStorage {
497
507
  return table;
498
508
  }
499
509
  async ensureSchema(schema) {
500
- // Memoized fast path: same instance, same schema version. D1 storages
501
- // are typically constructed per request — a fresh instance pays exactly
502
- // one marker read below when the version already matches.
503
- if (this.#schemaVersion === schema.version)
510
+ if (this.#schemaVersion === schema.version) {
511
+ await this.#schemaDatabase().batch([]);
504
512
  return;
505
- for (const table of schema.tables.values()) {
506
- const bindCount = tableColumnNames(table).length;
507
- if (bindCount > D1_MAX_BIND_PARAMS) {
508
- throw new Error(`table ${JSON.stringify(table.name)} needs ${bindCount} bound parameters per upsert; D1 caps statements at ${D1_MAX_BIND_PARAMS}`);
513
+ }
514
+ if (!(await this.migrateSchema(schema)).complete) {
515
+ throw new StorageQueryError('sync.storage.schema_migration_pending');
516
+ }
517
+ }
518
+ /** Run once per Worker invocation. Resume an incomplete result in another invocation. */
519
+ async migrateSchema(schema, options = {}) {
520
+ const maxStatements = options.maxStatements ?? 50;
521
+ if (!Number.isInteger(maxStatements) ||
522
+ maxStatements < 10 ||
523
+ maxStatements > 1000) {
524
+ throw new StorageQueryError('sync.storage.invalid_migration_budget');
525
+ }
526
+ const tables = [...schema.tables.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
527
+ for (const table of tables) {
528
+ if (tableColumnNames(table).length > D1_MAX_BIND_PARAMS) {
529
+ throw new Error('D1 caps statements at 100 bound parameters');
509
530
  }
510
531
  }
511
- await this.#db.exec(`${SCHEMA_META_DDL_SQLITE.replace(/\s+/g, ' ')};`);
512
- const marker = await this.#db
513
- .prepare('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1')
514
- .first();
532
+ const target = JSON.stringify({
533
+ version: schema.version,
534
+ tables: tables.map((table) => ({
535
+ name: table.name,
536
+ columns: table.columns,
537
+ primaryKeyIndex: table.primaryKeyIndex,
538
+ materialize: table.materialize,
539
+ indexes: table.indexes,
540
+ scopes: table.scopePatterns,
541
+ })),
542
+ });
543
+ let statementsExecuted = 0;
544
+ const read = async (statement) => {
545
+ statementsExecuted++;
546
+ return statement.first();
547
+ };
548
+ const batch = async (statements) => {
549
+ statementsExecuted += statements.length;
550
+ return this.#db.batch(statements);
551
+ };
552
+ await batch([
553
+ this.#db.prepare(SCHEMA_META_DDL_SQLITE),
554
+ this.#db.prepare(`CREATE TABLE IF NOT EXISTS sync_schema_migration(
555
+ id INTEGER PRIMARY KEY CHECK(id=1),
556
+ target TEXT NOT NULL,
557
+ source_layouts TEXT NOT NULL,
558
+ state TEXT NOT NULL
559
+ )`),
560
+ ]);
561
+ const marker = await read(this.#db.prepare('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1'));
562
+ let claim = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
563
+ if (claim === null && marker?.schema_version === schema.version) {
564
+ this.#tables = schema.tables;
565
+ this.#schemaVersion = schema.version;
566
+ return { complete: true, statementsExecuted };
567
+ }
515
568
  if (marker !== null && marker.schema_version > schema.version) {
516
- throw new Error(`stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
569
+ throw new StorageQueryError('sync.storage.schema_changed');
517
570
  }
518
- if (marker === null || marker.schema_version < schema.version) {
519
- await this.migrate();
571
+ if (claim === null) {
520
572
  const layouts = parseLayouts(marker?.layouts);
521
- const retiredTables = retiredTableNames(schema, layouts);
522
- const existing = new Map();
523
- const existingIndexes = new Map();
524
- for (const table of schema.tables.values()) {
525
- const escapedTableName = table.name.replaceAll('"', '""');
526
- const { results } = await this.#db
527
- .prepare(`PRAGMA table_info("${escapedTableName}")`)
528
- .all();
529
- if (results.length > 0) {
530
- existing.set(table.name, new Set(results.map((c) => c.name)));
531
- const indexes = await this.#db
532
- .prepare(`PRAGMA index_list("${escapedTableName}")`)
533
- .all();
534
- existingIndexes.set(table.name, new Set(indexes.results
535
- .filter((index) => index.origin === 'c')
536
- .map((index) => index.name)));
573
+ for (const table of tables) {
574
+ const oldLayout = layouts[table.name];
575
+ if (oldLayout !== undefined) {
576
+ assertAppendOnlyMigration(table.name, oldLayout, table);
537
577
  }
538
578
  }
539
- for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
540
- await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
579
+ const state = {
580
+ phase: 'core',
581
+ offset: 0,
582
+ columns: {},
583
+ indexes: {},
584
+ rewrites: [],
585
+ ddl: [],
586
+ afterPartition: '',
587
+ afterRowId: '',
588
+ };
589
+ // Claim the database before inspecting projection layouts. The marker
590
+ // comparison prevents a delayed caller from planning against an old schema.
591
+ try {
592
+ await batch([
593
+ this.#db
594
+ .prepare(`INSERT INTO sync_schema_migration
595
+ SELECT 2, '', '', '' WHERE EXISTS (SELECT 1 FROM sync_schema_meta
596
+ WHERE id=1 AND schema_version<>?) OR (?=0 AND EXISTS (SELECT 1 FROM sync_schema_meta WHERE id=1))`)
597
+ .bind(marker?.schema_version ?? 0, marker === null ? 0 : 1),
598
+ this.#db
599
+ .prepare(`INSERT OR IGNORE INTO sync_schema_migration
600
+ (id, target, source_layouts, state) VALUES (1,?,?,?)`)
601
+ .bind(target, marker?.layouts ?? '{}', JSON.stringify(state)),
602
+ ]);
541
603
  }
542
- // Migration rewrite: payload re-encode on layout change and/or
543
- // projection backfill on flipped-on materialization. D1 has no
544
- // interactive transaction — the rewrite runs statement-at-a-time,
545
- // which is safe (each rewrite is idempotent and the marker only
546
- // advances after all rewrites land; a mid-run crash re-runs them).
547
- for (const table of schema.tables.values()) {
548
- const oldLayout = layouts[table.name];
549
- const plan = rewritePlan(table, oldLayout, existing.get(table.name));
550
- if (!plan.migrate && !plan.backfill)
551
- continue;
552
- await this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
604
+ catch (error) {
605
+ const current = await read(this.#db.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1'));
606
+ if (current?.schema_version !== marker?.schema_version)
607
+ return { complete: false, statementsExecuted };
608
+ throw error;
553
609
  }
554
- // Retire tables only after the additive DDL and rewrites succeed. D1
555
- // cannot wrap the whole bump in an interactive transaction, but this
556
- // ordering avoids destructive work before every fallible preparatory
557
- // step and the batch keeps table + live-scope cleanup atomic.
558
- if (retiredTables.length > 0) {
559
- await this.#db.batch(retiredTables.flatMap((tableName) => [
610
+ claim = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
611
+ }
612
+ if (claim === null || claim.target !== target) {
613
+ throw new StorageQueryError('sync.storage.schema_migration_conflict');
614
+ }
615
+ const sourceLayouts = parseLayouts(claim.source_layouts);
616
+ let state = JSON.parse(claim.state);
617
+ // Reserve one statement to distinguish a stale checkpoint from a database
618
+ // failure. A losing concurrent step returns incomplete without retrying here.
619
+ const save = async (next, statements, publish = false) => {
620
+ const prior = claim.state;
621
+ const encoded = JSON.stringify(next);
622
+ try {
623
+ await batch([
560
624
  this.#db
561
- .prepare('DELETE FROM sync_row_scopes WHERE tbl=?')
562
- .bind(tableName),
563
- this.#db.prepare(dropTableDdl(tableName)),
625
+ .prepare(`INSERT INTO sync_schema_migration
626
+ SELECT 2, '', '', '' WHERE NOT EXISTS (
627
+ SELECT 1 FROM sync_schema_migration WHERE id=1 AND target=? AND state=?)`)
628
+ .bind(target, prior),
629
+ ...statements,
630
+ publish
631
+ ? this.#db.prepare('DELETE FROM sync_schema_migration WHERE id=1')
632
+ : this.#db
633
+ .prepare('UPDATE sync_schema_migration SET state=? WHERE id=1')
634
+ .bind(encoded),
635
+ ]);
636
+ }
637
+ catch (error) {
638
+ const current = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
639
+ if (current === null ||
640
+ (current.target === target && current.state !== prior))
641
+ return false;
642
+ throw error;
643
+ }
644
+ claim.state = encoded;
645
+ state = next;
646
+ return true;
647
+ };
648
+ while (maxStatements - statementsExecuted >= 4) {
649
+ const remaining = maxStatements - statementsExecuted - 1;
650
+ if (state.phase === 'core') {
651
+ const ddl = sqliteDdlStatements();
652
+ const count = Math.min(ddl.length - state.offset, remaining - 2);
653
+ if (count > 0) {
654
+ const statements = ddl
655
+ .slice(state.offset, state.offset + count)
656
+ .map((sql) => this.#db.prepare(sql));
657
+ if (!(await save({ ...state, offset: state.offset + count }, statements)))
658
+ break;
659
+ continue;
660
+ }
661
+ if (remaining < 4)
662
+ break;
663
+ statementsExecuted++;
664
+ const columns = await this.#db
665
+ .prepare('PRAGMA table_info("sync_clients")')
666
+ .all();
667
+ const needsWireVersion = !columns.results.some((column) => column.name === 'wire_version');
668
+ if (!(await save({ ...state, phase: 'prepare', offset: 0 }, needsWireVersion
669
+ ? [
670
+ this.#db.prepare('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1'),
671
+ ]
672
+ : [])))
673
+ break;
674
+ }
675
+ else if (state.phase === 'prepare') {
676
+ const table = tables[state.offset];
677
+ if (table !== undefined) {
678
+ if (remaining < 4)
679
+ break;
680
+ statementsExecuted += 2;
681
+ const columns = await this.#db
682
+ .prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
683
+ .all();
684
+ const indexes = await this.#db
685
+ .prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
686
+ .all();
687
+ // Validate the old codec before any application DDL or row rewrite.
688
+ rewritePlan(table, sourceLayouts[table.name], columns.results.length > 0
689
+ ? new Set(columns.results.map((column) => column.name))
690
+ : undefined);
691
+ if (!(await save({
692
+ ...state,
693
+ offset: state.offset + 1,
694
+ columns: {
695
+ ...state.columns,
696
+ ...(columns.results.length > 0
697
+ ? {
698
+ [table.name]: columns.results.map((column) => column.name),
699
+ }
700
+ : {}),
701
+ },
702
+ indexes: {
703
+ ...state.indexes,
704
+ [table.name]: indexes.results
705
+ .filter((index) => index.origin === 'c')
706
+ .map((index) => index.name),
707
+ },
708
+ }, [])))
709
+ break;
710
+ continue;
711
+ }
712
+ const columns = new Map(Object.entries(state.columns).map(([name, names]) => [
713
+ name,
714
+ new Set(names),
564
715
  ]));
716
+ const indexes = new Map(Object.entries(state.indexes).map(([name, names]) => [
717
+ name,
718
+ new Set(names),
719
+ ]));
720
+ const rewrites = tables.flatMap((table) => {
721
+ const plan = rewritePlan(table, sourceLayouts[table.name], columns.get(table.name));
722
+ return plan.migrate || plan.backfill
723
+ ? [
724
+ {
725
+ table: table.name,
726
+ ...(plan.migrate
727
+ ? { oldLayout: sourceLayouts[table.name] }
728
+ : {}),
729
+ },
730
+ ]
731
+ : [];
732
+ });
733
+ const ddl = [
734
+ ...retiredTableNames(schema, sourceLayouts).flatMap((name) => [
735
+ `DELETE FROM sync_row_scopes WHERE tbl='${name.replaceAll("'", "''")}'`,
736
+ `DELETE FROM sync_blob_refs WHERE tbl='${name.replaceAll("'", "''")}'`,
737
+ dropTableDdl(name),
738
+ ]),
739
+ ...schemaDdl(schema, columns, 'sqlite', indexes),
740
+ ];
741
+ if (!(await save({
742
+ ...state,
743
+ phase: 'ddl',
744
+ offset: 0,
745
+ ddl,
746
+ rewrites,
747
+ columns: {},
748
+ indexes: {},
749
+ }, [])))
750
+ break;
751
+ }
752
+ else if (state.phase === 'ddl') {
753
+ const count = Math.min(state.ddl.length - state.offset, remaining - 2);
754
+ if (count === 0) {
755
+ if (!(await save({ ...state, phase: 'rewrite', offset: 0, ddl: [] }, [])))
756
+ break;
757
+ }
758
+ else if (!(await save({ ...state, offset: state.offset + count }, state.ddl
759
+ .slice(state.offset, state.offset + count)
760
+ .map((sql) => this.#db.prepare(sql)))))
761
+ break;
762
+ }
763
+ else {
764
+ const plan = state.rewrites[state.offset];
765
+ if (plan === undefined) {
766
+ if (!(await save(state, [
767
+ this.#db
768
+ .prepare(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1,?,?)
769
+ ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts`)
770
+ .bind(schema.version, layoutsOf(schema)),
771
+ ], true)))
772
+ break;
773
+ this.#tables = schema.tables;
774
+ this.#schemaVersion = schema.version;
775
+ return { complete: true, statementsExecuted };
776
+ }
777
+ const table = schema.tables.get(plan.table);
778
+ if (table === undefined)
779
+ throw new StorageQueryError('sync.storage.schema_migration_conflict');
780
+ if (remaining < 4)
781
+ break;
782
+ const limit = Math.min(32, remaining - 3);
783
+ statementsExecuted++;
784
+ const { results } = await this.#db
785
+ .prepare(`SELECT * FROM (${selectRowsForRewriteSql(table, 'sqlite')})
786
+ WHERE EXISTS (SELECT 1 FROM sync_schema_migration WHERE id=1 AND target=? AND state=?)`)
787
+ .bind(state.afterPartition, state.afterRowId, limit, target, claim.state)
788
+ .all();
789
+ const statements = results.map((row) => {
790
+ const bytes = asUint8Array(row.payload);
791
+ const payload = plan.oldLayout !== undefined
792
+ ? migratePayload(plan.oldLayout, table, bytes)
793
+ : bytes;
794
+ return this.#db
795
+ .prepare(rewriteRowSql(table, 'sqlite'))
796
+ .bind(...rewriteValues(table, row.partition, row.row_id, payload, 'sqlite'));
797
+ });
798
+ const last = results.at(-1);
799
+ const done = results.length < limit;
800
+ if (!(await save({
801
+ ...state,
802
+ offset: state.offset + (done ? 1 : 0),
803
+ afterPartition: done ? '' : last.partition,
804
+ afterRowId: done ? '' : last.row_id,
805
+ }, statements)))
806
+ break;
565
807
  }
566
- await this.#db
567
- .prepare('INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts')
568
- .bind(schema.version, layoutsOf(schema))
569
- .run();
570
808
  }
571
- this.#tables = schema.tables;
572
- this.#schemaVersion = schema.version;
809
+ return { complete: false, statementsExecuted };
810
+ }
811
+ /** Every protected read or write shares a transaction with its schema check. */
812
+ #schemaDatabase() {
813
+ if (this.#schemaVersion === undefined)
814
+ throw new StorageQueryError('sync.storage.schema_changed');
815
+ const db = this.#db;
816
+ const version = this.#schemaVersion;
817
+ const sources = new WeakMap();
818
+ const execute = async (statements) => {
819
+ try {
820
+ const results = await db.batch([
821
+ db
822
+ .prepare(`INSERT INTO sync_schema_migration SELECT 2, '', '', ''
823
+ WHERE EXISTS (SELECT 1 FROM sync_schema_migration WHERE id=1)
824
+ OR NOT EXISTS (SELECT 1 FROM sync_schema_meta WHERE id=1 AND schema_version=?)`)
825
+ .bind(version),
826
+ ...statements,
827
+ ]);
828
+ return results.slice(1);
829
+ }
830
+ catch (error) {
831
+ const migration = await db
832
+ .prepare('SELECT id FROM sync_schema_migration WHERE id=1')
833
+ .first();
834
+ if (migration !== null)
835
+ throw new StorageQueryError('sync.storage.schema_migration_pending');
836
+ const marker = await db
837
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
838
+ .first();
839
+ if (marker?.schema_version !== version)
840
+ throw new StorageQueryError('sync.storage.schema_changed');
841
+ throw error;
842
+ }
843
+ };
844
+ const prepare = (sql, params = []) => {
845
+ const source = db.prepare(sql).bind(...params);
846
+ const all = async () => {
847
+ const result = (await execute([source]))[0];
848
+ if (typeof result !== 'object' ||
849
+ result === null ||
850
+ !('results' in result) ||
851
+ !Array.isArray(result.results)) {
852
+ throw new Error('D1 query returned an invalid batch result');
853
+ }
854
+ return { results: result.results };
855
+ };
856
+ const statement = {
857
+ bind: (...values) => prepare(sql, values),
858
+ all,
859
+ first: async () => (await all()).results[0] ?? null,
860
+ run: async () => (await execute([source]))[0],
861
+ };
862
+ sources.set(statement, source);
863
+ return statement;
864
+ };
865
+ return {
866
+ prepare,
867
+ batch: (statements) => execute(statements.map((statement) => {
868
+ const source = sources.get(statement);
869
+ if (source === undefined)
870
+ throw new Error('D1 batch contains a foreign statement');
871
+ return source;
872
+ })),
873
+ };
573
874
  }
574
875
  async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
575
876
  if (initialLogEpoch.length === 0) {
@@ -633,39 +934,15 @@ export class D1ServerStorage {
633
934
  lastAuthenticatedAtMs: row.last_authenticated_at_ms,
634
935
  }));
635
936
  }
636
- /** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
637
- async #rewriteRows(table, oldLayout) {
638
- const select = selectRowsForRewriteSql(table, 'sqlite');
639
- const update = rewriteRowSql(table, 'sqlite');
640
- const BATCH = 500;
641
- let afterPartition = '';
642
- let afterRowId = '';
643
- for (;;) {
644
- const { results } = await this.#db
645
- .prepare(select)
646
- .bind(afterPartition, afterRowId, BATCH)
647
- .all();
648
- if (results.length === 0)
649
- break;
650
- const statements = results.map((row) => {
651
- const bytes = asUint8Array(row.payload);
652
- const payload = oldLayout !== undefined
653
- ? migratePayload(oldLayout, table, bytes)
654
- : bytes;
655
- return this.#db
656
- .prepare(update)
657
- .bind(...rewriteValues(table, row.partition, row.row_id, payload, 'sqlite'));
658
- });
659
- await this.#db.batch(statements);
660
- const last = results[results.length - 1];
661
- if (last === undefined || results.length < BATCH)
662
- break;
663
- afterPartition = last.partition;
664
- afterRowId = last.row_id;
665
- }
666
- }
667
937
  async begin(partition) {
668
- return new D1Transaction(this.#db, partition, (name) => this.table(name), this.#pushApplySerialized);
938
+ const tables = this.#tables;
939
+ const db = this.#schemaDatabase();
940
+ return new D1Transaction(db, partition, (name) => {
941
+ const table = tables?.get(name);
942
+ if (table === undefined)
943
+ throw new StorageQueryError('sync.storage.schema_changed');
944
+ return table;
945
+ }, this.#pushApplySerialized);
669
946
  }
670
947
  async getMaxCommitSeq(partition) {
671
948
  const row = await this.#db
@@ -675,13 +952,14 @@ export class D1ServerStorage {
675
952
  return row?.max_commit_seq ?? 0;
676
953
  }
677
954
  async queryAuthoritative(partition, query) {
955
+ const db = this.#schemaDatabase();
678
956
  if (this.#tables === undefined) {
679
957
  throw new Error('ensureSchema(schema) must run before registered queries');
680
958
  }
681
959
  const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
682
- const results = await this.#db.batch([
683
- this.#db.prepare(prepared.sql).bind(...prepared.params),
684
- this.#db
960
+ const results = await db.batch([
961
+ db.prepare(prepared.sql).bind(...prepared.params),
962
+ db
685
963
  .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
686
964
  .bind(partition),
687
965
  ]);
@@ -783,14 +1061,16 @@ export class D1ServerStorage {
783
1061
  return row?.seq ?? 0;
784
1062
  }
785
1063
  async getRow(partition, table, rowId) {
786
- const record = await this.#db
1064
+ const db = this.#schemaDatabase();
1065
+ const record = await db
787
1066
  .prepare(selectRowSql(this.table(table), 'sqlite'))
788
1067
  .bind(partition, rowId)
789
1068
  .first();
790
1069
  return record === null ? undefined : toStoredRow(record);
791
1070
  }
792
1071
  async getPushResult(partition, clientId, clientCommitId) {
793
- const record = await this.#db
1072
+ const db = this.#schemaDatabase();
1073
+ const record = await db
794
1074
  .prepare('SELECT result FROM sync_push_results WHERE partition=? AND client_id=? AND client_commit_id=?')
795
1075
  .bind(partition, clientId, clientCommitId)
796
1076
  .first();
@@ -936,6 +1216,7 @@ export class D1ServerStorage {
936
1216
  };
937
1217
  }
938
1218
  async readCommitWindow(partition, query) {
1219
+ const db = this.#schemaDatabase();
939
1220
  const variables = Object.keys(query.scopeFilter).sort();
940
1221
  const firstVariable = variables[0];
941
1222
  if (firstVariable === undefined)
@@ -953,7 +1234,7 @@ export class D1ServerStorage {
953
1234
  let afterSeq = query.afterSeq;
954
1235
  const batchSize = Math.max(64, query.limitChanges);
955
1236
  while (deliveredChanges < query.limitChanges) {
956
- const { results: records } = await this.#db
1237
+ const { results: records } = await db
957
1238
  .prepare(sql)
958
1239
  .bind(partition, query.table, firstVariable, ...firstValues, afterSeq, query.throughSeq, batchSize, partition, partition, query.table)
959
1240
  .all();
@@ -969,6 +1250,7 @@ export class D1ServerStorage {
969
1250
  return commits;
970
1251
  }
971
1252
  async scanRows(partition, query) {
1253
+ const db = this.#schemaDatabase();
972
1254
  const firstVariable = assertScopeIndexedScan(query);
973
1255
  const firstValues = query.scopeFilter[firstVariable] ?? [];
974
1256
  if (firstValues.length === 0)
@@ -981,7 +1263,7 @@ export class D1ServerStorage {
981
1263
  let afterRowId = query.afterRowId ?? '';
982
1264
  const batchSize = Math.max(64, query.limit);
983
1265
  while (rows.length < query.limit) {
984
- const { results: records } = await this.#db
1266
+ const { results: records } = await db
985
1267
  .prepare(sql)
986
1268
  .bind(partition, query.table, firstVariable, ...firstValues, afterRowId, batchSize, partition)
987
1269
  .all();
@@ -1006,10 +1288,11 @@ export class D1ServerStorage {
1006
1288
  return rows;
1007
1289
  }
1008
1290
  async scanRowsByIndex(partition, query) {
1291
+ const db = this.#schemaDatabase();
1009
1292
  const table = this.table(query.table);
1010
1293
  const index = resolveIndexRowScan(table, query);
1011
1294
  const statement = indexRowPageStatement(table, index, query.values, partition, query.afterRowId, query.limit, 'sqlite');
1012
- const { results } = await this.#db
1295
+ const { results } = await db
1013
1296
  .prepare(statement.sql)
1014
1297
  .bind(...statement.params)
1015
1298
  .all();
@@ -1037,6 +1320,24 @@ export class D1ServerStorage {
1037
1320
  .bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
1038
1321
  .run();
1039
1322
  }
1323
+ async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
1324
+ await this.#db
1325
+ .prepare(`UPDATE sync_clients
1326
+ SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
1327
+ WHERE partition=? AND client_id=? AND actor_id=?
1328
+ AND EXISTS (SELECT 1 FROM sync_partition_registry
1329
+ WHERE partition=sync_clients.partition AND log_epoch=?)`)
1330
+ .bind(cursor, updatedAtMs, partition, clientId, actorId, logEpoch)
1331
+ .run();
1332
+ }
1333
+ async updateClientCursor(partition, clientId, cursor, updatedAtMs) {
1334
+ await this.#db
1335
+ .prepare(`UPDATE sync_clients
1336
+ SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
1337
+ WHERE partition=? AND client_id=?`)
1338
+ .bind(cursor, updatedAtMs, partition, clientId)
1339
+ .run();
1340
+ }
1040
1341
  async getActiveClientCursorFloor(partition, cutoffMs) {
1041
1342
  const row = await this.#db
1042
1343
  .prepare('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
@@ -1056,7 +1357,8 @@ export class D1ServerStorage {
1056
1357
  }));
1057
1358
  }
1058
1359
  async listRowsReferencingBlob(partition, blobId) {
1059
- const { results: refs } = await this.#db
1360
+ const db = this.#schemaDatabase();
1361
+ const { results: refs } = await db
1060
1362
  .prepare('SELECT tbl, row_id FROM sync_blob_refs WHERE partition=? AND blob_id=?')
1061
1363
  .bind(partition, blobId)
1062
1364
  .all();
@@ -1065,7 +1367,7 @@ export class D1ServerStorage {
1065
1367
  const compiled = this.#tables?.get(ref.tbl);
1066
1368
  if (compiled === undefined)
1067
1369
  continue; // table no longer in the schema
1068
- const row = await this.#db
1370
+ const row = await db
1069
1371
  .prepare(selectRowScopesSql(compiled, 'sqlite'))
1070
1372
  .bind(partition, ref.row_id)
1071
1373
  .first();
@@ -1167,7 +1469,8 @@ export class D1ServerStorage {
1167
1469
  return out;
1168
1470
  }
1169
1471
  async getRowScopes(partition, table, rowId) {
1170
- const record = await this.#db
1472
+ const db = this.#schemaDatabase();
1473
+ const record = await db
1171
1474
  .prepare(selectRowScopesSql(this.table(table), 'sqlite'))
1172
1475
  .bind(partition, rowId)
1173
1476
  .first();
@@ -67,6 +67,8 @@ export declare class PostgresServerStorage implements ServerStorage {
67
67
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
68
68
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
69
69
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
70
+ advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
71
+ updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
70
72
  getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
71
73
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
72
74
  listRowsReferencingBlob(partition: string, blobId: string): Promise<{