@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
package/src/d1-storage.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { validateCommitPruneQuery } from './prune';
2
+ import { StorageQueryError } from './storage-errors';
3
+ import type { CommitPruneQuery, CommitPruneResult } from './storage';
1
4
  /**
2
5
  * Cloudflare D1 server storage for Workers deployments.
3
6
  *
@@ -46,8 +49,10 @@ import {
46
49
  } from './authoritative-query';
47
50
  import { syncError } from './errors';
48
51
  import {
52
+ assertAppendOnlyMigration,
49
53
  commitWindowPageSql,
50
54
  deleteRowSql,
55
+ deleteSqliteRowScopesSql,
51
56
  dropTableDdl,
52
57
  indexRowPageStatement,
53
58
  layoutsOf,
@@ -202,7 +207,7 @@ type PendingRow =
202
207
  };
203
208
 
204
209
  class D1Transaction implements StorageTransaction {
205
- readonly #db: D1Database;
210
+ readonly #db: Pick<D1Database, 'prepare' | 'batch'>;
206
211
  readonly #partition: string;
207
212
  readonly #resolveTable: (name: string) => CompiledTable;
208
213
  readonly #pushApplySerialized: boolean;
@@ -230,7 +235,7 @@ class D1Transaction implements StorageTransaction {
230
235
  readonly #pending = new Map<string, PendingRow>();
231
236
 
232
237
  constructor(
233
- db: D1Database,
238
+ db: Pick<D1Database, 'prepare' | 'batch'>,
234
239
  partition: string,
235
240
  resolveTable: (name: string) => CompiledTable,
236
241
  pushApplySerialized: boolean,
@@ -529,14 +534,17 @@ class D1Transaction implements StorageTransaction {
529
534
  row,
530
535
  });
531
536
  const p = this.#partition;
537
+ this.#buffer_(deleteSqliteRowScopesSql(compiled), [
538
+ p,
539
+ table,
540
+ row.rowId,
541
+ p,
542
+ row.rowId,
543
+ ]);
532
544
  this.#buffer_(
533
545
  upsertSql(compiled, 'sqlite'),
534
546
  upsertValues(compiled, p, row, 'sqlite'),
535
547
  );
536
- this.#buffer_(
537
- 'DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?',
538
- [p, table, row.rowId],
539
- );
540
548
  for (const [variable, value] of Object.entries(row.scopes)) {
541
549
  this.#buffer_(
542
550
  'INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)',
@@ -549,14 +557,15 @@ class D1Transaction implements StorageTransaction {
549
557
  this.#assertOpen();
550
558
  this.#pending.set(D1Transaction.#key(table, rowId), { kind: 'deleted' });
551
559
  const p = this.#partition;
552
- this.#buffer_(deleteRowSql(this.#resolveTable(table), 'sqlite'), [
560
+ const compiled = this.#resolveTable(table);
561
+ this.#buffer_(deleteSqliteRowScopesSql(compiled), [
562
+ p,
563
+ table,
564
+ rowId,
553
565
  p,
554
566
  rowId,
555
567
  ]);
556
- this.#buffer_(
557
- 'DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?',
558
- [p, table, rowId],
559
- );
568
+ this.#buffer_(deleteRowSql(compiled, 'sqlite'), [p, rowId]);
560
569
  // §5.9.4: a deleted row references no blobs.
561
570
  this.#buffer_(
562
571
  'DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?',
@@ -734,6 +743,23 @@ export interface D1ServerStorageOptions {
734
743
  readonly commitValidationSerialized?: boolean;
735
744
  }
736
745
 
746
+ interface D1MigrationState {
747
+ phase: 'core' | 'prepare' | 'ddl' | 'rewrite';
748
+ offset: number;
749
+ columns: Record<string, string[]>;
750
+ indexes: Record<string, string[]>;
751
+ ddl: string[];
752
+ rewrites: Array<{ table: string; oldLayout?: readonly StoredColumnLayout[] }>;
753
+ afterPartition: string;
754
+ afterRowId: string;
755
+ }
756
+
757
+ interface D1MigrationClaim {
758
+ target: string;
759
+ source_layouts: string;
760
+ state: string;
761
+ }
762
+
737
763
  export class D1ServerStorage implements ServerStorage {
738
764
  readonly #db: D1Database;
739
765
  readonly #pushApplySerialized: boolean;
@@ -775,97 +801,480 @@ export class D1ServerStorage implements ServerStorage {
775
801
  }
776
802
 
777
803
  async ensureSchema(schema: CompiledSchema): Promise<void> {
778
- // Memoized fast path: same instance, same schema version. D1 storages
779
- // are typically constructed per request — a fresh instance pays exactly
780
- // one marker read below when the version already matches.
781
- if (this.#schemaVersion === schema.version) return;
782
- for (const table of schema.tables.values()) {
783
- const bindCount = tableColumnNames(table).length;
784
- if (bindCount > D1_MAX_BIND_PARAMS) {
785
- throw new Error(
786
- `table ${JSON.stringify(table.name)} needs ${bindCount} bound parameters per upsert; D1 caps statements at ${D1_MAX_BIND_PARAMS}`,
787
- );
804
+ if (this.#schemaVersion === schema.version) {
805
+ await this.#schemaDatabase().batch([]);
806
+ return;
807
+ }
808
+ if (!(await this.migrateSchema(schema)).complete) {
809
+ throw new StorageQueryError('sync.storage.schema_migration_pending');
810
+ }
811
+ }
812
+
813
+ /** Run once per Worker invocation. Resume an incomplete result in another invocation. */
814
+ async migrateSchema(
815
+ schema: CompiledSchema,
816
+ options: { readonly maxStatements?: number } = {},
817
+ ): Promise<{
818
+ readonly complete: boolean;
819
+ readonly statementsExecuted: number;
820
+ }> {
821
+ const maxStatements = options.maxStatements ?? 50;
822
+ if (
823
+ !Number.isInteger(maxStatements) ||
824
+ maxStatements < 10 ||
825
+ maxStatements > 1000
826
+ ) {
827
+ throw new StorageQueryError('sync.storage.invalid_migration_budget');
828
+ }
829
+ const tables = [...schema.tables.values()].sort((a, b) =>
830
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
831
+ );
832
+ for (const table of tables) {
833
+ if (tableColumnNames(table).length > D1_MAX_BIND_PARAMS) {
834
+ throw new Error('D1 caps statements at 100 bound parameters');
788
835
  }
789
836
  }
790
- await this.#db.exec(`${SCHEMA_META_DDL_SQLITE.replace(/\s+/g, ' ')};`);
791
- const marker = await this.#db
792
- .prepare(
837
+ const target = JSON.stringify({
838
+ version: schema.version,
839
+ tables: tables.map((table) => ({
840
+ name: table.name,
841
+ columns: table.columns,
842
+ primaryKeyIndex: table.primaryKeyIndex,
843
+ materialize: table.materialize,
844
+ indexes: table.indexes,
845
+ scopes: table.scopePatterns,
846
+ })),
847
+ });
848
+ let statementsExecuted = 0;
849
+ const read = async <T>(
850
+ statement: D1PreparedStatement,
851
+ ): Promise<T | null> => {
852
+ statementsExecuted++;
853
+ return statement.first<T>();
854
+ };
855
+ const batch = async (
856
+ statements: D1PreparedStatement[],
857
+ ): Promise<unknown[]> => {
858
+ statementsExecuted += statements.length;
859
+ return this.#db.batch(statements);
860
+ };
861
+ await batch([
862
+ this.#db.prepare(SCHEMA_META_DDL_SQLITE),
863
+ this.#db.prepare(`CREATE TABLE IF NOT EXISTS sync_schema_migration(
864
+ id INTEGER PRIMARY KEY CHECK(id=1),
865
+ target TEXT NOT NULL,
866
+ source_layouts TEXT NOT NULL,
867
+ state TEXT NOT NULL
868
+ )`),
869
+ ]);
870
+ const marker = await read<{ schema_version: number; layouts: string }>(
871
+ this.#db.prepare(
793
872
  'SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1',
794
- )
795
- .first<{ schema_version: number; layouts: string }>();
873
+ ),
874
+ );
875
+ let claim = await read<D1MigrationClaim>(
876
+ this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'),
877
+ );
878
+ if (claim === null && marker?.schema_version === schema.version) {
879
+ this.#tables = schema.tables;
880
+ this.#schemaVersion = schema.version;
881
+ return { complete: true, statementsExecuted };
882
+ }
796
883
  if (marker !== null && marker.schema_version > schema.version) {
797
- throw new Error(
798
- `stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
799
- );
884
+ throw new StorageQueryError('sync.storage.schema_changed');
800
885
  }
801
- if (marker === null || marker.schema_version < schema.version) {
802
- await this.migrate();
886
+ if (claim === null) {
803
887
  const layouts = parseLayouts(marker?.layouts);
804
- const retiredTables = retiredTableNames(schema, layouts);
805
- const existing = new Map<string, ReadonlySet<string>>();
806
- const existingIndexes = new Map<string, ReadonlySet<string>>();
807
- for (const table of schema.tables.values()) {
808
- const escapedTableName = table.name.replaceAll('"', '""');
809
- const { results } = await this.#db
810
- .prepare(`PRAGMA table_info("${escapedTableName}")`)
888
+ for (const table of tables) {
889
+ const oldLayout = layouts[table.name];
890
+ if (oldLayout !== undefined) {
891
+ assertAppendOnlyMigration(table.name, oldLayout, table);
892
+ }
893
+ }
894
+ const state: D1MigrationState = {
895
+ phase: 'core',
896
+ offset: 0,
897
+ columns: {},
898
+ indexes: {},
899
+ rewrites: [],
900
+ ddl: [],
901
+ afterPartition: '',
902
+ afterRowId: '',
903
+ };
904
+ // Claim the database before inspecting projection layouts. The marker
905
+ // comparison prevents a delayed caller from planning against an old schema.
906
+ try {
907
+ await batch([
908
+ this.#db
909
+ .prepare(`INSERT INTO sync_schema_migration
910
+ SELECT 2, '', '', '' WHERE EXISTS (SELECT 1 FROM sync_schema_meta
911
+ WHERE id=1 AND schema_version<>?) OR (?=0 AND EXISTS (SELECT 1 FROM sync_schema_meta WHERE id=1))`)
912
+ .bind(marker?.schema_version ?? 0, marker === null ? 0 : 1),
913
+ this.#db
914
+ .prepare(`INSERT OR IGNORE INTO sync_schema_migration
915
+ (id, target, source_layouts, state) VALUES (1,?,?,?)`)
916
+ .bind(target, marker?.layouts ?? '{}', JSON.stringify(state)),
917
+ ]);
918
+ } catch (error) {
919
+ const current = await read<{ schema_version: number }>(
920
+ this.#db.prepare(
921
+ 'SELECT schema_version FROM sync_schema_meta WHERE id=1',
922
+ ),
923
+ );
924
+ if (current?.schema_version !== marker?.schema_version)
925
+ return { complete: false, statementsExecuted };
926
+ throw error;
927
+ }
928
+ claim = await read<D1MigrationClaim>(
929
+ this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'),
930
+ );
931
+ }
932
+ if (claim === null || claim.target !== target) {
933
+ throw new StorageQueryError('sync.storage.schema_migration_conflict');
934
+ }
935
+ const sourceLayouts = parseLayouts(claim.source_layouts);
936
+ let state = JSON.parse(claim.state) as D1MigrationState;
937
+ // Reserve one statement to distinguish a stale checkpoint from a database
938
+ // failure. A losing concurrent step returns incomplete without retrying here.
939
+ const save = async (
940
+ next: D1MigrationState,
941
+ statements: D1PreparedStatement[],
942
+ publish = false,
943
+ ): Promise<boolean> => {
944
+ const prior = claim.state;
945
+ const encoded = JSON.stringify(next);
946
+ try {
947
+ await batch([
948
+ this.#db
949
+ .prepare(`INSERT INTO sync_schema_migration
950
+ SELECT 2, '', '', '' WHERE NOT EXISTS (
951
+ SELECT 1 FROM sync_schema_migration WHERE id=1 AND target=? AND state=?)`)
952
+ .bind(target, prior),
953
+ ...statements,
954
+ publish
955
+ ? this.#db.prepare('DELETE FROM sync_schema_migration WHERE id=1')
956
+ : this.#db
957
+ .prepare('UPDATE sync_schema_migration SET state=? WHERE id=1')
958
+ .bind(encoded),
959
+ ]);
960
+ } catch (error) {
961
+ const current = await read<D1MigrationClaim>(
962
+ this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'),
963
+ );
964
+ if (
965
+ current === null ||
966
+ (current.target === target && current.state !== prior)
967
+ )
968
+ return false;
969
+ throw error;
970
+ }
971
+ claim.state = encoded;
972
+ state = next;
973
+ return true;
974
+ };
975
+ while (maxStatements - statementsExecuted >= 4) {
976
+ const remaining = maxStatements - statementsExecuted - 1;
977
+ if (state.phase === 'core') {
978
+ const ddl = sqliteDdlStatements();
979
+ const count = Math.min(ddl.length - state.offset, remaining - 2);
980
+ if (count > 0) {
981
+ const statements = ddl
982
+ .slice(state.offset, state.offset + count)
983
+ .map((sql) => this.#db.prepare(sql));
984
+ if (
985
+ !(await save(
986
+ { ...state, offset: state.offset + count },
987
+ statements,
988
+ ))
989
+ )
990
+ break;
991
+ continue;
992
+ }
993
+ if (remaining < 4) break;
994
+ statementsExecuted++;
995
+ const columns = await this.#db
996
+ .prepare('PRAGMA table_info("sync_clients")')
811
997
  .all<{ name: string }>();
812
- if (results.length > 0) {
813
- existing.set(table.name, new Set(results.map((c) => c.name)));
998
+ const needsWireVersion = !columns.results.some(
999
+ (column) => column.name === 'wire_version',
1000
+ );
1001
+ if (
1002
+ !(await save(
1003
+ { ...state, phase: 'prepare', offset: 0 },
1004
+ needsWireVersion
1005
+ ? [
1006
+ this.#db.prepare(
1007
+ 'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
1008
+ ),
1009
+ ]
1010
+ : [],
1011
+ ))
1012
+ )
1013
+ break;
1014
+ } else if (state.phase === 'prepare') {
1015
+ const table = tables[state.offset];
1016
+ if (table !== undefined) {
1017
+ if (remaining < 4) break;
1018
+ statementsExecuted += 2;
1019
+ const columns = await this.#db
1020
+ .prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
1021
+ .all<{ name: string }>();
814
1022
  const indexes = await this.#db
815
- .prepare(`PRAGMA index_list("${escapedTableName}")`)
1023
+ .prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
816
1024
  .all<{ name: string; origin: string }>();
817
- existingIndexes.set(
818
- table.name,
819
- new Set(
820
- indexes.results
821
- .filter((index) => index.origin === 'c')
822
- .map((index) => index.name),
823
- ),
1025
+ // Validate the old codec before any application DDL or row rewrite.
1026
+ rewritePlan(
1027
+ table,
1028
+ sourceLayouts[table.name],
1029
+ columns.results.length > 0
1030
+ ? new Set(columns.results.map((column) => column.name))
1031
+ : undefined,
824
1032
  );
1033
+ if (
1034
+ !(await save(
1035
+ {
1036
+ ...state,
1037
+ offset: state.offset + 1,
1038
+ columns: {
1039
+ ...state.columns,
1040
+ ...(columns.results.length > 0
1041
+ ? {
1042
+ [table.name]: columns.results.map(
1043
+ (column) => column.name,
1044
+ ),
1045
+ }
1046
+ : {}),
1047
+ },
1048
+ indexes: {
1049
+ ...state.indexes,
1050
+ [table.name]: indexes.results
1051
+ .filter((index) => index.origin === 'c')
1052
+ .map((index) => index.name),
1053
+ },
1054
+ },
1055
+ [],
1056
+ ))
1057
+ )
1058
+ break;
1059
+ continue;
825
1060
  }
826
- }
827
- for (const statement of schemaDdl(
828
- schema,
829
- existing,
830
- 'sqlite',
831
- existingIndexes,
832
- )) {
833
- await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
834
- }
835
- // Migration rewrite: payload re-encode on layout change and/or
836
- // projection backfill on flipped-on materialization. D1 has no
837
- // interactive transaction — the rewrite runs statement-at-a-time,
838
- // which is safe (each rewrite is idempotent and the marker only
839
- // advances after all rewrites land; a mid-run crash re-runs them).
840
- for (const table of schema.tables.values()) {
841
- const oldLayout = layouts[table.name];
842
- const plan = rewritePlan(table, oldLayout, existing.get(table.name));
843
- if (!plan.migrate && !plan.backfill) continue;
844
- await this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
845
- }
846
- // Retire tables only after the additive DDL and rewrites succeed. D1
847
- // cannot wrap the whole bump in an interactive transaction, but this
848
- // ordering avoids destructive work before every fallible preparatory
849
- // step and the batch keeps table + live-scope cleanup atomic.
850
- if (retiredTables.length > 0) {
851
- await this.#db.batch(
852
- retiredTables.flatMap((tableName) => [
853
- this.#db
854
- .prepare('DELETE FROM sync_row_scopes WHERE tbl=?')
855
- .bind(tableName),
856
- this.#db.prepare(dropTableDdl(tableName)),
1061
+ const columns = new Map(
1062
+ Object.entries(state.columns).map(([name, names]) => [
1063
+ name,
1064
+ new Set(names),
857
1065
  ]),
858
1066
  );
859
- }
860
- await this.#db
861
- .prepare(
862
- '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',
1067
+ const indexes = new Map(
1068
+ Object.entries(state.indexes).map(([name, names]) => [
1069
+ name,
1070
+ new Set(names),
1071
+ ]),
1072
+ );
1073
+ const rewrites = tables.flatMap((table) => {
1074
+ const plan = rewritePlan(
1075
+ table,
1076
+ sourceLayouts[table.name],
1077
+ columns.get(table.name),
1078
+ );
1079
+ return plan.migrate || plan.backfill
1080
+ ? [
1081
+ {
1082
+ table: table.name,
1083
+ ...(plan.migrate
1084
+ ? { oldLayout: sourceLayouts[table.name] }
1085
+ : {}),
1086
+ },
1087
+ ]
1088
+ : [];
1089
+ });
1090
+ const ddl = [
1091
+ ...retiredTableNames(schema, sourceLayouts).flatMap((name) => [
1092
+ `DELETE FROM sync_row_scopes WHERE tbl='${name.replaceAll("'", "''")}'`,
1093
+ `DELETE FROM sync_blob_refs WHERE tbl='${name.replaceAll("'", "''")}'`,
1094
+ dropTableDdl(name),
1095
+ ]),
1096
+ ...schemaDdl(schema, columns, 'sqlite', indexes),
1097
+ ];
1098
+ if (
1099
+ !(await save(
1100
+ {
1101
+ ...state,
1102
+ phase: 'ddl',
1103
+ offset: 0,
1104
+ ddl,
1105
+ rewrites,
1106
+ columns: {},
1107
+ indexes: {},
1108
+ },
1109
+ [],
1110
+ ))
1111
+ )
1112
+ break;
1113
+ } else if (state.phase === 'ddl') {
1114
+ const count = Math.min(state.ddl.length - state.offset, remaining - 2);
1115
+ if (count === 0) {
1116
+ if (
1117
+ !(await save(
1118
+ { ...state, phase: 'rewrite', offset: 0, ddl: [] },
1119
+ [],
1120
+ ))
1121
+ )
1122
+ break;
1123
+ } else if (
1124
+ !(await save(
1125
+ { ...state, offset: state.offset + count },
1126
+ state.ddl
1127
+ .slice(state.offset, state.offset + count)
1128
+ .map((sql) => this.#db.prepare(sql)),
1129
+ ))
1130
+ )
1131
+ break;
1132
+ } else {
1133
+ const plan = state.rewrites[state.offset];
1134
+ if (plan === undefined) {
1135
+ if (
1136
+ !(await save(
1137
+ state,
1138
+ [
1139
+ this.#db
1140
+ .prepare(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1,?,?)
1141
+ ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts`)
1142
+ .bind(schema.version, layoutsOf(schema)),
1143
+ ],
1144
+ true,
1145
+ ))
1146
+ )
1147
+ break;
1148
+ this.#tables = schema.tables;
1149
+ this.#schemaVersion = schema.version;
1150
+ return { complete: true, statementsExecuted };
1151
+ }
1152
+ const table = schema.tables.get(plan.table);
1153
+ if (table === undefined)
1154
+ throw new StorageQueryError('sync.storage.schema_migration_conflict');
1155
+ if (remaining < 4) break;
1156
+ const limit = Math.min(32, remaining - 3);
1157
+ statementsExecuted++;
1158
+ const { results } = await this.#db
1159
+ .prepare(`SELECT * FROM (${selectRowsForRewriteSql(table, 'sqlite')})
1160
+ WHERE EXISTS (SELECT 1 FROM sync_schema_migration WHERE id=1 AND target=? AND state=?)`)
1161
+ .bind(
1162
+ state.afterPartition,
1163
+ state.afterRowId,
1164
+ limit,
1165
+ target,
1166
+ claim.state,
1167
+ )
1168
+ .all<{ partition: string; row_id: string; payload: unknown }>();
1169
+ const statements = results.map((row) => {
1170
+ const bytes = asUint8Array(row.payload);
1171
+ const payload =
1172
+ plan.oldLayout !== undefined
1173
+ ? migratePayload(plan.oldLayout, table, bytes)
1174
+ : bytes;
1175
+ return this.#db
1176
+ .prepare(rewriteRowSql(table, 'sqlite'))
1177
+ .bind(
1178
+ ...rewriteValues(
1179
+ table,
1180
+ row.partition,
1181
+ row.row_id,
1182
+ payload,
1183
+ 'sqlite',
1184
+ ),
1185
+ );
1186
+ });
1187
+ const last = results.at(-1);
1188
+ const done = results.length < limit;
1189
+ if (
1190
+ !(await save(
1191
+ {
1192
+ ...state,
1193
+ offset: state.offset + (done ? 1 : 0),
1194
+ afterPartition: done ? '' : last!.partition,
1195
+ afterRowId: done ? '' : last!.row_id,
1196
+ },
1197
+ statements,
1198
+ ))
863
1199
  )
864
- .bind(schema.version, layoutsOf(schema))
865
- .run();
1200
+ break;
1201
+ }
866
1202
  }
867
- this.#tables = schema.tables;
868
- this.#schemaVersion = schema.version;
1203
+ return { complete: false, statementsExecuted };
1204
+ }
1205
+
1206
+ /** Every protected read or write shares a transaction with its schema check. */
1207
+ #schemaDatabase(): Pick<D1Database, 'prepare' | 'batch'> {
1208
+ if (this.#schemaVersion === undefined)
1209
+ throw new StorageQueryError('sync.storage.schema_changed');
1210
+ const db = this.#db;
1211
+ const version = this.#schemaVersion;
1212
+ const sources = new WeakMap<D1PreparedStatement, D1PreparedStatement>();
1213
+ const execute = async (
1214
+ statements: D1PreparedStatement[],
1215
+ ): Promise<unknown[]> => {
1216
+ try {
1217
+ const results = await db.batch([
1218
+ db
1219
+ .prepare(`INSERT INTO sync_schema_migration SELECT 2, '', '', ''
1220
+ WHERE EXISTS (SELECT 1 FROM sync_schema_migration WHERE id=1)
1221
+ OR NOT EXISTS (SELECT 1 FROM sync_schema_meta WHERE id=1 AND schema_version=?)`)
1222
+ .bind(version),
1223
+ ...statements,
1224
+ ]);
1225
+ return results.slice(1);
1226
+ } catch (error) {
1227
+ const migration = await db
1228
+ .prepare('SELECT id FROM sync_schema_migration WHERE id=1')
1229
+ .first();
1230
+ if (migration !== null)
1231
+ throw new StorageQueryError('sync.storage.schema_migration_pending');
1232
+ const marker = await db
1233
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
1234
+ .first<{ schema_version: number }>();
1235
+ if (marker?.schema_version !== version)
1236
+ throw new StorageQueryError('sync.storage.schema_changed');
1237
+ throw error;
1238
+ }
1239
+ };
1240
+ const prepare = (
1241
+ sql: string,
1242
+ params: unknown[] = [],
1243
+ ): D1PreparedStatement => {
1244
+ const source = db.prepare(sql).bind(...params);
1245
+ const all = async <T>(): Promise<{ results: T[] }> => {
1246
+ const result = (await execute([source]))[0];
1247
+ if (
1248
+ typeof result !== 'object' ||
1249
+ result === null ||
1250
+ !('results' in result) ||
1251
+ !Array.isArray(result.results)
1252
+ ) {
1253
+ throw new Error('D1 query returned an invalid batch result');
1254
+ }
1255
+ return { results: result.results as T[] };
1256
+ };
1257
+ const statement: D1PreparedStatement = {
1258
+ bind: (...values) => prepare(sql, values),
1259
+ all,
1260
+ first: async <T>() => (await all<T>()).results[0] ?? null,
1261
+ run: async () => (await execute([source]))[0],
1262
+ };
1263
+ sources.set(statement, source);
1264
+ return statement;
1265
+ };
1266
+ return {
1267
+ prepare,
1268
+ batch: (statements) =>
1269
+ execute(
1270
+ statements.map((statement) => {
1271
+ const source = sources.get(statement);
1272
+ if (source === undefined)
1273
+ throw new Error('D1 batch contains a foreign statement');
1274
+ return source;
1275
+ }),
1276
+ ),
1277
+ };
869
1278
  }
870
1279
 
871
1280
  async touchPartition(
@@ -957,53 +1366,18 @@ export class D1ServerStorage implements ServerStorage {
957
1366
  }));
958
1367
  }
959
1368
 
960
- /** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
961
- async #rewriteRows(
962
- table: CompiledTable,
963
- oldLayout: readonly StoredColumnLayout[] | undefined,
964
- ): Promise<void> {
965
- const select = selectRowsForRewriteSql(table, 'sqlite');
966
- const update = rewriteRowSql(table, 'sqlite');
967
- const BATCH = 500;
968
- let afterPartition = '';
969
- let afterRowId = '';
970
- for (;;) {
971
- const { results } = await this.#db
972
- .prepare(select)
973
- .bind(afterPartition, afterRowId, BATCH)
974
- .all<{ partition: string; row_id: string; payload: unknown }>();
975
- if (results.length === 0) break;
976
- const statements = results.map((row) => {
977
- const bytes = asUint8Array(row.payload);
978
- const payload =
979
- oldLayout !== undefined
980
- ? migratePayload(oldLayout, table, bytes)
981
- : bytes;
982
- return this.#db
983
- .prepare(update)
984
- .bind(
985
- ...rewriteValues(
986
- table,
987
- row.partition,
988
- row.row_id,
989
- payload,
990
- 'sqlite',
991
- ),
992
- );
993
- });
994
- await this.#db.batch(statements);
995
- const last = results[results.length - 1];
996
- if (last === undefined || results.length < BATCH) break;
997
- afterPartition = last.partition;
998
- afterRowId = last.row_id;
999
- }
1000
- }
1001
-
1002
1369
  async begin(partition: string): Promise<StorageTransaction> {
1370
+ const tables = this.#tables;
1371
+ const db = this.#schemaDatabase();
1003
1372
  return new D1Transaction(
1004
- this.#db,
1373
+ db,
1005
1374
  partition,
1006
- (name) => this.table(name),
1375
+ (name) => {
1376
+ const table = tables?.get(name);
1377
+ if (table === undefined)
1378
+ throw new StorageQueryError('sync.storage.schema_changed');
1379
+ return table;
1380
+ },
1007
1381
  this.#pushApplySerialized,
1008
1382
  );
1009
1383
  }
@@ -1020,6 +1394,7 @@ export class D1ServerStorage implements ServerStorage {
1020
1394
  partition: string,
1021
1395
  query: AuthoritativeQueryRequest,
1022
1396
  ): Promise<AuthoritativeQueryResult> {
1397
+ const db = this.#schemaDatabase();
1023
1398
  if (this.#tables === undefined) {
1024
1399
  throw new Error(
1025
1400
  'ensureSchema(schema) must run before registered queries',
@@ -1027,16 +1402,16 @@ export class D1ServerStorage implements ServerStorage {
1027
1402
  }
1028
1403
  const prepared = bindAuthoritativePartition(
1029
1404
  prepareAuthoritativeQuery(
1030
- query.sql,
1405
+ query.plan,
1031
1406
  query.params,
1032
1407
  query.tables,
1033
1408
  this.#tables,
1034
1409
  ),
1035
1410
  partition,
1036
1411
  );
1037
- const results = await this.#db.batch([
1038
- this.#db.prepare(prepared.sql).bind(...prepared.params),
1039
- this.#db
1412
+ const results = await db.batch([
1413
+ db.prepare(prepared.sql).bind(...prepared.params),
1414
+ db
1040
1415
  .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
1041
1416
  .bind(partition),
1042
1417
  ]);
@@ -1071,6 +1446,16 @@ export class D1ServerStorage implements ServerStorage {
1071
1446
  };
1072
1447
  }
1073
1448
 
1449
+ async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
1450
+ const row = await this.#db
1451
+ .prepare(
1452
+ 'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
1453
+ )
1454
+ .bind(partition)
1455
+ .first<{ log_epoch: string }>();
1456
+ return row?.log_epoch;
1457
+ }
1458
+
1074
1459
  async getHorizonSeq(partition: string): Promise<number> {
1075
1460
  const row = await this.#db
1076
1461
  .prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -1082,33 +1467,76 @@ export class D1ServerStorage implements ServerStorage {
1082
1467
  async setHorizonSeq(partition: string, seq: number): Promise<void> {
1083
1468
  await this.#db
1084
1469
  .prepare(
1085
- 'INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq',
1470
+ 'INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)',
1086
1471
  )
1087
1472
  .bind(partition, seq)
1088
1473
  .run();
1089
1474
  }
1090
1475
 
1091
- async pruneCommitsThrough(partition: string, seq: number): Promise<number> {
1092
- const before = await this.#db
1093
- .prepare(
1094
- 'SELECT count(*) AS n FROM sync_commits WHERE partition=? AND commit_seq<=?',
1095
- )
1096
- .bind(partition, seq)
1097
- .first<{ n: number }>();
1098
- await this.#db.batch([
1476
+ async pruneCommitsThrough(
1477
+ partition: string,
1478
+ query: CommitPruneQuery,
1479
+ ): Promise<CommitPruneResult> {
1480
+ validateCommitPruneQuery(query);
1481
+ if (!this.#pushApplySerialized)
1482
+ throw new Error(
1483
+ 'D1 pruning requires externally serialized partition writes',
1484
+ );
1485
+ const epochGuard =
1486
+ 'EXISTS (SELECT 1 FROM sync_partition_registry WHERE partition=? AND log_epoch=?)';
1487
+ const horizon =
1488
+ '(SELECT horizon_seq FROM sync_partitions WHERE partition=?)';
1489
+ const results = await this.#db.batch([
1099
1490
  this.#db
1100
- .prepare('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
1101
- .bind(partition, seq),
1491
+ .prepare(
1492
+ `SELECT log_epoch, coalesce(${horizon},0) AS previous_horizon_seq FROM sync_partition_registry WHERE partition=?`,
1493
+ )
1494
+ .bind(partition, partition),
1102
1495
  this.#db
1103
- .prepare('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
1104
- .bind(partition, seq),
1496
+ .prepare(`INSERT INTO sync_partitions(partition,horizon_seq) SELECT ?,? WHERE ${epochGuard}
1497
+ ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
1498
+ .bind(partition, query.throughSeq, partition, query.logEpoch),
1105
1499
  this.#db
1106
1500
  .prepare(
1107
- 'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
1501
+ `SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`,
1108
1502
  )
1109
- .bind(partition, seq),
1503
+ .bind(partition, partition, partition),
1504
+ ...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) =>
1505
+ this.#db
1506
+ .prepare(
1507
+ `DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`,
1508
+ )
1509
+ .bind(partition, partition, partition, query.logEpoch),
1510
+ ),
1110
1511
  ]);
1111
- return before?.n ?? 0;
1512
+ const [before, after] = [results[0], results[2]].map((result) => {
1513
+ if (
1514
+ typeof result !== 'object' ||
1515
+ result === null ||
1516
+ !('results' in result) ||
1517
+ !Array.isArray(result.results)
1518
+ ) {
1519
+ throw new Error('D1 pruning returned an invalid batch result');
1520
+ }
1521
+ const row: unknown = result.results[0];
1522
+ return typeof row === 'object' && row !== null
1523
+ ? (row as Readonly<Record<string, unknown>>)
1524
+ : undefined;
1525
+ });
1526
+ if (before?.log_epoch !== query.logEpoch)
1527
+ throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
1528
+ if (
1529
+ typeof before.previous_horizon_seq !== 'number' ||
1530
+ typeof after?.horizon_seq !== 'number' ||
1531
+ typeof after.removed_commits !== 'number'
1532
+ ) {
1533
+ throw new Error('D1 pruning returned invalid horizon metadata');
1534
+ }
1535
+ return {
1536
+ previousHorizonSeq: before.previous_horizon_seq,
1537
+ horizonSeq: after.horizon_seq,
1538
+ removedCommits: after.removed_commits,
1539
+ };
1112
1540
  }
1113
1541
 
1114
1542
  async getCommitSeqBefore(
@@ -1129,7 +1557,8 @@ export class D1ServerStorage implements ServerStorage {
1129
1557
  table: string,
1130
1558
  rowId: string,
1131
1559
  ): Promise<StoredRow | undefined> {
1132
- const record = await this.#db
1560
+ const db = this.#schemaDatabase();
1561
+ const record = await db
1133
1562
  .prepare(selectRowSql(this.table(table), 'sqlite'))
1134
1563
  .bind(partition, rowId)
1135
1564
  .first<SqliteRowRecord>();
@@ -1141,7 +1570,8 @@ export class D1ServerStorage implements ServerStorage {
1141
1570
  clientId: string,
1142
1571
  clientCommitId: string,
1143
1572
  ): Promise<StoredPushResult | undefined> {
1144
- const record = await this.#db
1573
+ const db = this.#schemaDatabase();
1574
+ const record = await db
1145
1575
  .prepare(
1146
1576
  'SELECT result FROM sync_push_results WHERE partition=? AND client_id=? AND client_commit_id=?',
1147
1577
  )
@@ -1372,6 +1802,7 @@ export class D1ServerStorage implements ServerStorage {
1372
1802
  partition: string,
1373
1803
  query: CommitWindowQuery,
1374
1804
  ): Promise<StoredCommit[]> {
1805
+ const db = this.#schemaDatabase();
1375
1806
  const variables = Object.keys(query.scopeFilter).sort();
1376
1807
  const firstVariable = variables[0];
1377
1808
  if (firstVariable === undefined) return [];
@@ -1387,7 +1818,7 @@ export class D1ServerStorage implements ServerStorage {
1387
1818
  let afterSeq = query.afterSeq;
1388
1819
  const batchSize = Math.max(64, query.limitChanges);
1389
1820
  while (deliveredChanges < query.limitChanges) {
1390
- const { results: records } = await this.#db
1821
+ const { results: records } = await db
1391
1822
  .prepare(sql)
1392
1823
  .bind(
1393
1824
  partition,
@@ -1417,6 +1848,7 @@ export class D1ServerStorage implements ServerStorage {
1417
1848
  }
1418
1849
 
1419
1850
  async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
1851
+ const db = this.#schemaDatabase();
1420
1852
  const firstVariable = assertScopeIndexedScan(query);
1421
1853
  const firstValues = query.scopeFilter[firstVariable] ?? [];
1422
1854
  if (firstValues.length === 0) return [];
@@ -1432,7 +1864,7 @@ export class D1ServerStorage implements ServerStorage {
1432
1864
  let afterRowId = query.afterRowId ?? '';
1433
1865
  const batchSize = Math.max(64, query.limit);
1434
1866
  while (rows.length < query.limit) {
1435
- const { results: records } = await this.#db
1867
+ const { results: records } = await db
1436
1868
  .prepare(sql)
1437
1869
  .bind(
1438
1870
  partition,
@@ -1464,6 +1896,7 @@ export class D1ServerStorage implements ServerStorage {
1464
1896
  partition: string,
1465
1897
  query: IndexRowScanQuery,
1466
1898
  ): Promise<StoredRow[]> {
1899
+ const db = this.#schemaDatabase();
1467
1900
  const table = this.table(query.table);
1468
1901
  const index = resolveIndexRowScan(table, query);
1469
1902
  const statement = indexRowPageStatement(
@@ -1475,7 +1908,7 @@ export class D1ServerStorage implements ServerStorage {
1475
1908
  query.limit,
1476
1909
  'sqlite',
1477
1910
  );
1478
- const { results } = await this.#db
1911
+ const { results } = await db
1479
1912
  .prepare(statement.sql)
1480
1913
  .bind(...statement.params)
1481
1914
  .all<SqliteRowRecord>();
@@ -1530,6 +1963,53 @@ export class D1ServerStorage implements ServerStorage {
1530
1963
  .run();
1531
1964
  }
1532
1965
 
1966
+ async advanceClientCursor(
1967
+ partition: string,
1968
+ clientId: string,
1969
+ actorId: string,
1970
+ logEpoch: string,
1971
+ cursor: number,
1972
+ updatedAtMs: number,
1973
+ ): Promise<void> {
1974
+ await this.#db
1975
+ .prepare(`UPDATE sync_clients
1976
+ SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
1977
+ WHERE partition=? AND client_id=? AND actor_id=?
1978
+ AND EXISTS (SELECT 1 FROM sync_partition_registry
1979
+ WHERE partition=sync_clients.partition AND log_epoch=?)`)
1980
+ .bind(cursor, updatedAtMs, partition, clientId, actorId, logEpoch)
1981
+ .run();
1982
+ }
1983
+
1984
+ async updateClientCursor(
1985
+ partition: string,
1986
+ clientId: string,
1987
+ cursor: number,
1988
+ updatedAtMs: number,
1989
+ ): Promise<void> {
1990
+ await this.#db
1991
+ .prepare(
1992
+ `UPDATE sync_clients
1993
+ SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
1994
+ WHERE partition=? AND client_id=?`,
1995
+ )
1996
+ .bind(cursor, updatedAtMs, partition, clientId)
1997
+ .run();
1998
+ }
1999
+
2000
+ async getActiveClientCursorFloor(
2001
+ partition: string,
2002
+ cutoffMs: number,
2003
+ ): Promise<number | null> {
2004
+ const row = await this.#db
2005
+ .prepare(
2006
+ 'SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?',
2007
+ )
2008
+ .bind(partition, cutoffMs)
2009
+ .first<{ cursor: number | null }>();
2010
+ return row!.cursor;
2011
+ }
2012
+
1533
2013
  async listClientCursors(partition: string): Promise<ClientCursorInfo[]> {
1534
2014
  const { results } = await this.#db
1535
2015
  .prepare(
@@ -1554,7 +2034,8 @@ export class D1ServerStorage implements ServerStorage {
1554
2034
  readonly scopes: Record<string, string>;
1555
2035
  }[]
1556
2036
  > {
1557
- const { results: refs } = await this.#db
2037
+ const db = this.#schemaDatabase();
2038
+ const { results: refs } = await db
1558
2039
  .prepare(
1559
2040
  'SELECT tbl, row_id FROM sync_blob_refs WHERE partition=? AND blob_id=?',
1560
2041
  )
@@ -1568,7 +2049,7 @@ export class D1ServerStorage implements ServerStorage {
1568
2049
  for (const ref of refs) {
1569
2050
  const compiled = this.#tables?.get(ref.tbl);
1570
2051
  if (compiled === undefined) continue; // table no longer in the schema
1571
- const row = await this.#db
2052
+ const row = await db
1572
2053
  .prepare(selectRowScopesSql(compiled, 'sqlite'))
1573
2054
  .bind(partition, ref.row_id)
1574
2055
  .first<{ scopes: string }>();
@@ -1719,7 +2200,8 @@ export class D1ServerStorage implements ServerStorage {
1719
2200
  ): Promise<
1720
2201
  { serverVersion: number; scopes: Record<string, string> } | undefined
1721
2202
  > {
1722
- const record = await this.#db
2203
+ const db = this.#schemaDatabase();
2204
+ const record = await db
1723
2205
  .prepare(selectRowScopesSql(this.table(table), 'sqlite'))
1724
2206
  .bind(partition, rowId)
1725
2207
  .first<{ server_version: number; scopes: string }>();