@syncular/server 0.15.48 → 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.
Files changed (44) hide show
  1. package/README.md +66 -9
  2. package/dist/admin.js +1 -5
  3. package/dist/authoritative-query.d.ts +14 -6
  4. package/dist/authoritative-query.js +60 -82
  5. package/dist/d1-storage.d.ts +13 -1
  6. package/dist/d1-storage.js +468 -121
  7. package/dist/operations.d.ts +2 -0
  8. package/dist/operations.js +23 -4
  9. package/dist/postgres-storage.d.ts +6 -1
  10. package/dist/postgres-storage.js +80 -24
  11. package/dist/prune.d.ts +3 -1
  12. package/dist/prune.js +18 -14
  13. package/dist/pull.js +79 -39
  14. package/dist/push.js +5 -5
  15. package/dist/realtime.d.ts +1 -1
  16. package/dist/realtime.js +16 -10
  17. package/dist/relational-rows.d.ts +7 -1
  18. package/dist/relational-rows.js +17 -2
  19. package/dist/sqlite-bun.js +2 -2
  20. package/dist/sqlite-image.d.ts +3 -3
  21. package/dist/sqlite-image.js +10 -6
  22. package/dist/sqlite-node.js +2 -2
  23. package/dist/sqlite-storage.d.ts +6 -1
  24. package/dist/sqlite-storage.js +93 -32
  25. package/dist/storage-errors.d.ts +1 -1
  26. package/dist/storage-errors.js +7 -0
  27. package/dist/storage.d.ts +29 -5
  28. package/package.json +2 -2
  29. package/src/admin.ts +4 -6
  30. package/src/authoritative-query.ts +89 -94
  31. package/src/d1-storage.ts +641 -159
  32. package/src/operations.ts +31 -3
  33. package/src/postgres-storage.ts +146 -46
  34. package/src/prune.ts +29 -15
  35. package/src/pull.ts +102 -49
  36. package/src/push.ts +5 -5
  37. package/src/realtime.ts +20 -9
  38. package/src/relational-rows.ts +18 -2
  39. package/src/sqlite-bun.ts +2 -2
  40. package/src/sqlite-image.ts +26 -15
  41. package/src/sqlite-node.ts +2 -2
  42. package/src/sqlite-storage.ts +131 -37
  43. package/src/storage-errors.ts +22 -1
  44. package/src/storage.ts +50 -5
@@ -1,4 +1,6 @@
1
1
  var _a;
2
+ import { validateCommitPruneQuery } from './prune.js';
3
+ import { StorageQueryError } from './storage-errors.js';
2
4
  /**
3
5
  * Cloudflare D1 server storage for Workers deployments.
4
6
  *
@@ -43,7 +45,7 @@ var _a;
43
45
  import { decodeRow } from '@syncular/core';
44
46
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
45
47
  import { syncError } from './errors.js';
46
- 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';
47
49
  import { matchesEffective } from './scopes.js';
48
50
  import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
49
51
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
@@ -331,8 +333,14 @@ class D1Transaction {
331
333
  row,
332
334
  });
333
335
  const p = this.#partition;
336
+ this.#buffer_(deleteSqliteRowScopesSql(compiled), [
337
+ p,
338
+ table,
339
+ row.rowId,
340
+ p,
341
+ row.rowId,
342
+ ]);
334
343
  this.#buffer_(upsertSql(compiled, 'sqlite'), upsertValues(compiled, p, row, 'sqlite'));
335
- this.#buffer_('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?', [p, table, row.rowId]);
336
344
  for (const [variable, value] of Object.entries(row.scopes)) {
337
345
  this.#buffer_('INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)', [p, table, variable, value, row.rowId]);
338
346
  }
@@ -341,11 +349,15 @@ class D1Transaction {
341
349
  this.#assertOpen();
342
350
  this.#pending.set(_a.#key(table, rowId), { kind: 'deleted' });
343
351
  const p = this.#partition;
344
- this.#buffer_(deleteRowSql(this.#resolveTable(table), 'sqlite'), [
352
+ const compiled = this.#resolveTable(table);
353
+ this.#buffer_(deleteSqliteRowScopesSql(compiled), [
354
+ p,
355
+ table,
356
+ rowId,
345
357
  p,
346
358
  rowId,
347
359
  ]);
348
- 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]);
349
361
  // §5.9.4: a deleted row references no blobs.
350
362
  this.#buffer_('DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?', [p, table, rowId]);
351
363
  }
@@ -495,79 +507,370 @@ export class D1ServerStorage {
495
507
  return table;
496
508
  }
497
509
  async ensureSchema(schema) {
498
- // Memoized fast path: same instance, same schema version. D1 storages
499
- // are typically constructed per request — a fresh instance pays exactly
500
- // one marker read below when the version already matches.
501
- if (this.#schemaVersion === schema.version)
510
+ if (this.#schemaVersion === schema.version) {
511
+ await this.#schemaDatabase().batch([]);
502
512
  return;
503
- for (const table of schema.tables.values()) {
504
- const bindCount = tableColumnNames(table).length;
505
- if (bindCount > D1_MAX_BIND_PARAMS) {
506
- 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');
507
530
  }
508
531
  }
509
- await this.#db.exec(`${SCHEMA_META_DDL_SQLITE.replace(/\s+/g, ' ')};`);
510
- const marker = await this.#db
511
- .prepare('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1')
512
- .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
+ }
513
568
  if (marker !== null && marker.schema_version > schema.version) {
514
- 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');
515
570
  }
516
- if (marker === null || marker.schema_version < schema.version) {
517
- await this.migrate();
571
+ if (claim === null) {
518
572
  const layouts = parseLayouts(marker?.layouts);
519
- const retiredTables = retiredTableNames(schema, layouts);
520
- const existing = new Map();
521
- const existingIndexes = new Map();
522
- for (const table of schema.tables.values()) {
523
- const escapedTableName = table.name.replaceAll('"', '""');
524
- const { results } = await this.#db
525
- .prepare(`PRAGMA table_info("${escapedTableName}")`)
526
- .all();
527
- if (results.length > 0) {
528
- existing.set(table.name, new Set(results.map((c) => c.name)));
529
- const indexes = await this.#db
530
- .prepare(`PRAGMA index_list("${escapedTableName}")`)
531
- .all();
532
- existingIndexes.set(table.name, new Set(indexes.results
533
- .filter((index) => index.origin === 'c')
534
- .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);
535
577
  }
536
578
  }
537
- for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
538
- 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
+ ]);
539
603
  }
540
- // Migration rewrite: payload re-encode on layout change and/or
541
- // projection backfill on flipped-on materialization. D1 has no
542
- // interactive transaction — the rewrite runs statement-at-a-time,
543
- // which is safe (each rewrite is idempotent and the marker only
544
- // advances after all rewrites land; a mid-run crash re-runs them).
545
- for (const table of schema.tables.values()) {
546
- const oldLayout = layouts[table.name];
547
- const plan = rewritePlan(table, oldLayout, existing.get(table.name));
548
- if (!plan.migrate && !plan.backfill)
549
- continue;
550
- 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;
551
609
  }
552
- // Retire tables only after the additive DDL and rewrites succeed. D1
553
- // cannot wrap the whole bump in an interactive transaction, but this
554
- // ordering avoids destructive work before every fallible preparatory
555
- // step and the batch keeps table + live-scope cleanup atomic.
556
- if (retiredTables.length > 0) {
557
- 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([
558
624
  this.#db
559
- .prepare('DELETE FROM sync_row_scopes WHERE tbl=?')
560
- .bind(tableName),
561
- 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),
715
+ ]));
716
+ const indexes = new Map(Object.entries(state.indexes).map(([name, names]) => [
717
+ name,
718
+ new Set(names),
562
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;
563
807
  }
564
- await this.#db
565
- .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')
566
- .bind(schema.version, layoutsOf(schema))
567
- .run();
568
808
  }
569
- this.#tables = schema.tables;
570
- 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
+ };
571
874
  }
572
875
  async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
573
876
  if (initialLogEpoch.length === 0) {
@@ -631,39 +934,15 @@ export class D1ServerStorage {
631
934
  lastAuthenticatedAtMs: row.last_authenticated_at_ms,
632
935
  }));
633
936
  }
634
- /** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
635
- async #rewriteRows(table, oldLayout) {
636
- const select = selectRowsForRewriteSql(table, 'sqlite');
637
- const update = rewriteRowSql(table, 'sqlite');
638
- const BATCH = 500;
639
- let afterPartition = '';
640
- let afterRowId = '';
641
- for (;;) {
642
- const { results } = await this.#db
643
- .prepare(select)
644
- .bind(afterPartition, afterRowId, BATCH)
645
- .all();
646
- if (results.length === 0)
647
- break;
648
- const statements = results.map((row) => {
649
- const bytes = asUint8Array(row.payload);
650
- const payload = oldLayout !== undefined
651
- ? migratePayload(oldLayout, table, bytes)
652
- : bytes;
653
- return this.#db
654
- .prepare(update)
655
- .bind(...rewriteValues(table, row.partition, row.row_id, payload, 'sqlite'));
656
- });
657
- await this.#db.batch(statements);
658
- const last = results[results.length - 1];
659
- if (last === undefined || results.length < BATCH)
660
- break;
661
- afterPartition = last.partition;
662
- afterRowId = last.row_id;
663
- }
664
- }
665
937
  async begin(partition) {
666
- 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);
667
946
  }
668
947
  async getMaxCommitSeq(partition) {
669
948
  const row = await this.#db
@@ -673,13 +952,14 @@ export class D1ServerStorage {
673
952
  return row?.max_commit_seq ?? 0;
674
953
  }
675
954
  async queryAuthoritative(partition, query) {
955
+ const db = this.#schemaDatabase();
676
956
  if (this.#tables === undefined) {
677
957
  throw new Error('ensureSchema(schema) must run before registered queries');
678
958
  }
679
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
680
- const results = await this.#db.batch([
681
- this.#db.prepare(prepared.sql).bind(...prepared.params),
682
- this.#db
959
+ const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
960
+ const results = await db.batch([
961
+ db.prepare(prepared.sql).bind(...prepared.params),
962
+ db
683
963
  .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
684
964
  .bind(partition),
685
965
  ]);
@@ -707,6 +987,13 @@ export class D1ServerStorage {
707
987
  maxCommitSeq,
708
988
  };
709
989
  }
990
+ async getPartitionLogEpoch(partition) {
991
+ const row = await this.#db
992
+ .prepare('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
993
+ .bind(partition)
994
+ .first();
995
+ return row?.log_epoch;
996
+ }
710
997
  async getHorizonSeq(partition) {
711
998
  const row = await this.#db
712
999
  .prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -716,27 +1003,55 @@ export class D1ServerStorage {
716
1003
  }
717
1004
  async setHorizonSeq(partition, seq) {
718
1005
  await this.#db
719
- .prepare('INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq')
1006
+ .prepare('INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)')
720
1007
  .bind(partition, seq)
721
1008
  .run();
722
1009
  }
723
- async pruneCommitsThrough(partition, seq) {
724
- const before = await this.#db
725
- .prepare('SELECT count(*) AS n FROM sync_commits WHERE partition=? AND commit_seq<=?')
726
- .bind(partition, seq)
727
- .first();
728
- await this.#db.batch([
1010
+ async pruneCommitsThrough(partition, query) {
1011
+ validateCommitPruneQuery(query);
1012
+ if (!this.#pushApplySerialized)
1013
+ throw new Error('D1 pruning requires externally serialized partition writes');
1014
+ const epochGuard = 'EXISTS (SELECT 1 FROM sync_partition_registry WHERE partition=? AND log_epoch=?)';
1015
+ const horizon = '(SELECT horizon_seq FROM sync_partitions WHERE partition=?)';
1016
+ const results = await this.#db.batch([
729
1017
  this.#db
730
- .prepare('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
731
- .bind(partition, seq),
1018
+ .prepare(`SELECT log_epoch, coalesce(${horizon},0) AS previous_horizon_seq FROM sync_partition_registry WHERE partition=?`)
1019
+ .bind(partition, partition),
732
1020
  this.#db
733
- .prepare('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
734
- .bind(partition, seq),
1021
+ .prepare(`INSERT INTO sync_partitions(partition,horizon_seq) SELECT ?,? WHERE ${epochGuard}
1022
+ ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
1023
+ .bind(partition, query.throughSeq, partition, query.logEpoch),
735
1024
  this.#db
736
- .prepare('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
737
- .bind(partition, seq),
1025
+ .prepare(`SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`)
1026
+ .bind(partition, partition, partition),
1027
+ ...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) => this.#db
1028
+ .prepare(`DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`)
1029
+ .bind(partition, partition, partition, query.logEpoch)),
738
1030
  ]);
739
- return before?.n ?? 0;
1031
+ const [before, after] = [results[0], results[2]].map((result) => {
1032
+ if (typeof result !== 'object' ||
1033
+ result === null ||
1034
+ !('results' in result) ||
1035
+ !Array.isArray(result.results)) {
1036
+ throw new Error('D1 pruning returned an invalid batch result');
1037
+ }
1038
+ const row = result.results[0];
1039
+ return typeof row === 'object' && row !== null
1040
+ ? row
1041
+ : undefined;
1042
+ });
1043
+ if (before?.log_epoch !== query.logEpoch)
1044
+ throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
1045
+ if (typeof before.previous_horizon_seq !== 'number' ||
1046
+ typeof after?.horizon_seq !== 'number' ||
1047
+ typeof after.removed_commits !== 'number') {
1048
+ throw new Error('D1 pruning returned invalid horizon metadata');
1049
+ }
1050
+ return {
1051
+ previousHorizonSeq: before.previous_horizon_seq,
1052
+ horizonSeq: after.horizon_seq,
1053
+ removedCommits: after.removed_commits,
1054
+ };
740
1055
  }
741
1056
  async getCommitSeqBefore(partition, createdBeforeMs) {
742
1057
  const row = await this.#db
@@ -746,14 +1061,16 @@ export class D1ServerStorage {
746
1061
  return row?.seq ?? 0;
747
1062
  }
748
1063
  async getRow(partition, table, rowId) {
749
- const record = await this.#db
1064
+ const db = this.#schemaDatabase();
1065
+ const record = await db
750
1066
  .prepare(selectRowSql(this.table(table), 'sqlite'))
751
1067
  .bind(partition, rowId)
752
1068
  .first();
753
1069
  return record === null ? undefined : toStoredRow(record);
754
1070
  }
755
1071
  async getPushResult(partition, clientId, clientCommitId) {
756
- const record = await this.#db
1072
+ const db = this.#schemaDatabase();
1073
+ const record = await db
757
1074
  .prepare('SELECT result FROM sync_push_results WHERE partition=? AND client_id=? AND client_commit_id=?')
758
1075
  .bind(partition, clientId, clientCommitId)
759
1076
  .first();
@@ -899,6 +1216,7 @@ export class D1ServerStorage {
899
1216
  };
900
1217
  }
901
1218
  async readCommitWindow(partition, query) {
1219
+ const db = this.#schemaDatabase();
902
1220
  const variables = Object.keys(query.scopeFilter).sort();
903
1221
  const firstVariable = variables[0];
904
1222
  if (firstVariable === undefined)
@@ -916,7 +1234,7 @@ export class D1ServerStorage {
916
1234
  let afterSeq = query.afterSeq;
917
1235
  const batchSize = Math.max(64, query.limitChanges);
918
1236
  while (deliveredChanges < query.limitChanges) {
919
- const { results: records } = await this.#db
1237
+ const { results: records } = await db
920
1238
  .prepare(sql)
921
1239
  .bind(partition, query.table, firstVariable, ...firstValues, afterSeq, query.throughSeq, batchSize, partition, partition, query.table)
922
1240
  .all();
@@ -932,6 +1250,7 @@ export class D1ServerStorage {
932
1250
  return commits;
933
1251
  }
934
1252
  async scanRows(partition, query) {
1253
+ const db = this.#schemaDatabase();
935
1254
  const firstVariable = assertScopeIndexedScan(query);
936
1255
  const firstValues = query.scopeFilter[firstVariable] ?? [];
937
1256
  if (firstValues.length === 0)
@@ -944,7 +1263,7 @@ export class D1ServerStorage {
944
1263
  let afterRowId = query.afterRowId ?? '';
945
1264
  const batchSize = Math.max(64, query.limit);
946
1265
  while (rows.length < query.limit) {
947
- const { results: records } = await this.#db
1266
+ const { results: records } = await db
948
1267
  .prepare(sql)
949
1268
  .bind(partition, query.table, firstVariable, ...firstValues, afterRowId, batchSize, partition)
950
1269
  .all();
@@ -969,10 +1288,11 @@ export class D1ServerStorage {
969
1288
  return rows;
970
1289
  }
971
1290
  async scanRowsByIndex(partition, query) {
1291
+ const db = this.#schemaDatabase();
972
1292
  const table = this.table(query.table);
973
1293
  const index = resolveIndexRowScan(table, query);
974
1294
  const statement = indexRowPageStatement(table, index, query.values, partition, query.afterRowId, query.limit, 'sqlite');
975
- const { results } = await this.#db
1295
+ const { results } = await db
976
1296
  .prepare(statement.sql)
977
1297
  .bind(...statement.params)
978
1298
  .all();
@@ -1000,6 +1320,31 @@ export class D1ServerStorage {
1000
1320
  .bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
1001
1321
  .run();
1002
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
+ }
1341
+ async getActiveClientCursorFloor(partition, cutoffMs) {
1342
+ const row = await this.#db
1343
+ .prepare('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
1344
+ .bind(partition, cutoffMs)
1345
+ .first();
1346
+ return row.cursor;
1347
+ }
1003
1348
  async listClientCursors(partition) {
1004
1349
  const { results } = await this.#db
1005
1350
  .prepare('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=?')
@@ -1012,7 +1357,8 @@ export class D1ServerStorage {
1012
1357
  }));
1013
1358
  }
1014
1359
  async listRowsReferencingBlob(partition, blobId) {
1015
- const { results: refs } = await this.#db
1360
+ const db = this.#schemaDatabase();
1361
+ const { results: refs } = await db
1016
1362
  .prepare('SELECT tbl, row_id FROM sync_blob_refs WHERE partition=? AND blob_id=?')
1017
1363
  .bind(partition, blobId)
1018
1364
  .all();
@@ -1021,7 +1367,7 @@ export class D1ServerStorage {
1021
1367
  const compiled = this.#tables?.get(ref.tbl);
1022
1368
  if (compiled === undefined)
1023
1369
  continue; // table no longer in the schema
1024
- const row = await this.#db
1370
+ const row = await db
1025
1371
  .prepare(selectRowScopesSql(compiled, 'sqlite'))
1026
1372
  .bind(partition, ref.row_id)
1027
1373
  .first();
@@ -1123,7 +1469,8 @@ export class D1ServerStorage {
1123
1469
  return out;
1124
1470
  }
1125
1471
  async getRowScopes(partition, table, rowId) {
1126
- const record = await this.#db
1472
+ const db = this.#schemaDatabase();
1473
+ const record = await db
1127
1474
  .prepare(selectRowScopesSql(this.table(table), 'sqlite'))
1128
1475
  .bind(partition, rowId)
1129
1476
  .first();