@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.
package/src/d1-storage.ts CHANGED
@@ -49,8 +49,10 @@ import {
49
49
  } from './authoritative-query';
50
50
  import { syncError } from './errors';
51
51
  import {
52
+ assertAppendOnlyMigration,
52
53
  commitWindowPageSql,
53
54
  deleteRowSql,
55
+ deleteSqliteRowScopesSql,
54
56
  dropTableDdl,
55
57
  indexRowPageStatement,
56
58
  layoutsOf,
@@ -205,7 +207,7 @@ type PendingRow =
205
207
  };
206
208
 
207
209
  class D1Transaction implements StorageTransaction {
208
- readonly #db: D1Database;
210
+ readonly #db: Pick<D1Database, 'prepare' | 'batch'>;
209
211
  readonly #partition: string;
210
212
  readonly #resolveTable: (name: string) => CompiledTable;
211
213
  readonly #pushApplySerialized: boolean;
@@ -233,7 +235,7 @@ class D1Transaction implements StorageTransaction {
233
235
  readonly #pending = new Map<string, PendingRow>();
234
236
 
235
237
  constructor(
236
- db: D1Database,
238
+ db: Pick<D1Database, 'prepare' | 'batch'>,
237
239
  partition: string,
238
240
  resolveTable: (name: string) => CompiledTable,
239
241
  pushApplySerialized: boolean,
@@ -532,14 +534,17 @@ class D1Transaction implements StorageTransaction {
532
534
  row,
533
535
  });
534
536
  const p = this.#partition;
537
+ this.#buffer_(deleteSqliteRowScopesSql(compiled), [
538
+ p,
539
+ table,
540
+ row.rowId,
541
+ p,
542
+ row.rowId,
543
+ ]);
535
544
  this.#buffer_(
536
545
  upsertSql(compiled, 'sqlite'),
537
546
  upsertValues(compiled, p, row, 'sqlite'),
538
547
  );
539
- this.#buffer_(
540
- 'DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?',
541
- [p, table, row.rowId],
542
- );
543
548
  for (const [variable, value] of Object.entries(row.scopes)) {
544
549
  this.#buffer_(
545
550
  'INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)',
@@ -552,14 +557,15 @@ class D1Transaction implements StorageTransaction {
552
557
  this.#assertOpen();
553
558
  this.#pending.set(D1Transaction.#key(table, rowId), { kind: 'deleted' });
554
559
  const p = this.#partition;
555
- this.#buffer_(deleteRowSql(this.#resolveTable(table), 'sqlite'), [
560
+ const compiled = this.#resolveTable(table);
561
+ this.#buffer_(deleteSqliteRowScopesSql(compiled), [
562
+ p,
563
+ table,
564
+ rowId,
556
565
  p,
557
566
  rowId,
558
567
  ]);
559
- this.#buffer_(
560
- 'DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?',
561
- [p, table, rowId],
562
- );
568
+ this.#buffer_(deleteRowSql(compiled, 'sqlite'), [p, rowId]);
563
569
  // §5.9.4: a deleted row references no blobs.
564
570
  this.#buffer_(
565
571
  'DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?',
@@ -737,6 +743,23 @@ export interface D1ServerStorageOptions {
737
743
  readonly commitValidationSerialized?: boolean;
738
744
  }
739
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
+
740
763
  export class D1ServerStorage implements ServerStorage {
741
764
  readonly #db: D1Database;
742
765
  readonly #pushApplySerialized: boolean;
@@ -778,97 +801,480 @@ export class D1ServerStorage implements ServerStorage {
778
801
  }
779
802
 
780
803
  async ensureSchema(schema: CompiledSchema): Promise<void> {
781
- // Memoized fast path: same instance, same schema version. D1 storages
782
- // are typically constructed per request — a fresh instance pays exactly
783
- // one marker read below when the version already matches.
784
- if (this.#schemaVersion === schema.version) return;
785
- for (const table of schema.tables.values()) {
786
- const bindCount = tableColumnNames(table).length;
787
- if (bindCount > D1_MAX_BIND_PARAMS) {
788
- throw new Error(
789
- `table ${JSON.stringify(table.name)} needs ${bindCount} bound parameters per upsert; D1 caps statements at ${D1_MAX_BIND_PARAMS}`,
790
- );
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');
791
835
  }
792
836
  }
793
- await this.#db.exec(`${SCHEMA_META_DDL_SQLITE.replace(/\s+/g, ' ')};`);
794
- const marker = await this.#db
795
- .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(
796
872
  'SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1',
797
- )
798
- .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
+ }
799
883
  if (marker !== null && marker.schema_version > schema.version) {
800
- throw new Error(
801
- `stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
802
- );
884
+ throw new StorageQueryError('sync.storage.schema_changed');
803
885
  }
804
- if (marker === null || marker.schema_version < schema.version) {
805
- await this.migrate();
886
+ if (claim === null) {
806
887
  const layouts = parseLayouts(marker?.layouts);
807
- const retiredTables = retiredTableNames(schema, layouts);
808
- const existing = new Map<string, ReadonlySet<string>>();
809
- const existingIndexes = new Map<string, ReadonlySet<string>>();
810
- for (const table of schema.tables.values()) {
811
- const escapedTableName = table.name.replaceAll('"', '""');
812
- const { results } = await this.#db
813
- .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")')
814
997
  .all<{ name: string }>();
815
- if (results.length > 0) {
816
- 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 }>();
817
1022
  const indexes = await this.#db
818
- .prepare(`PRAGMA index_list("${escapedTableName}")`)
1023
+ .prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
819
1024
  .all<{ name: string; origin: string }>();
820
- existingIndexes.set(
821
- table.name,
822
- new Set(
823
- indexes.results
824
- .filter((index) => index.origin === 'c')
825
- .map((index) => index.name),
826
- ),
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,
827
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;
828
1060
  }
829
- }
830
- for (const statement of schemaDdl(
831
- schema,
832
- existing,
833
- 'sqlite',
834
- existingIndexes,
835
- )) {
836
- await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
837
- }
838
- // Migration rewrite: payload re-encode on layout change and/or
839
- // projection backfill on flipped-on materialization. D1 has no
840
- // interactive transaction — the rewrite runs statement-at-a-time,
841
- // which is safe (each rewrite is idempotent and the marker only
842
- // advances after all rewrites land; a mid-run crash re-runs them).
843
- for (const table of schema.tables.values()) {
844
- const oldLayout = layouts[table.name];
845
- const plan = rewritePlan(table, oldLayout, existing.get(table.name));
846
- if (!plan.migrate && !plan.backfill) continue;
847
- await this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
848
- }
849
- // Retire tables only after the additive DDL and rewrites succeed. D1
850
- // cannot wrap the whole bump in an interactive transaction, but this
851
- // ordering avoids destructive work before every fallible preparatory
852
- // step and the batch keeps table + live-scope cleanup atomic.
853
- if (retiredTables.length > 0) {
854
- await this.#db.batch(
855
- retiredTables.flatMap((tableName) => [
856
- this.#db
857
- .prepare('DELETE FROM sync_row_scopes WHERE tbl=?')
858
- .bind(tableName),
859
- this.#db.prepare(dropTableDdl(tableName)),
1061
+ const columns = new Map(
1062
+ Object.entries(state.columns).map(([name, names]) => [
1063
+ name,
1064
+ new Set(names),
860
1065
  ]),
861
1066
  );
862
- }
863
- await this.#db
864
- .prepare(
865
- '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
+ ))
866
1111
  )
867
- .bind(schema.version, layoutsOf(schema))
868
- .run();
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
+ ))
1199
+ )
1200
+ break;
1201
+ }
869
1202
  }
870
- this.#tables = schema.tables;
871
- 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
+ };
872
1278
  }
873
1279
 
874
1280
  async touchPartition(
@@ -960,53 +1366,18 @@ export class D1ServerStorage implements ServerStorage {
960
1366
  }));
961
1367
  }
962
1368
 
963
- /** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
964
- async #rewriteRows(
965
- table: CompiledTable,
966
- oldLayout: readonly StoredColumnLayout[] | undefined,
967
- ): Promise<void> {
968
- const select = selectRowsForRewriteSql(table, 'sqlite');
969
- const update = rewriteRowSql(table, 'sqlite');
970
- const BATCH = 500;
971
- let afterPartition = '';
972
- let afterRowId = '';
973
- for (;;) {
974
- const { results } = await this.#db
975
- .prepare(select)
976
- .bind(afterPartition, afterRowId, BATCH)
977
- .all<{ partition: string; row_id: string; payload: unknown }>();
978
- if (results.length === 0) break;
979
- const statements = results.map((row) => {
980
- const bytes = asUint8Array(row.payload);
981
- const payload =
982
- oldLayout !== undefined
983
- ? migratePayload(oldLayout, table, bytes)
984
- : bytes;
985
- return this.#db
986
- .prepare(update)
987
- .bind(
988
- ...rewriteValues(
989
- table,
990
- row.partition,
991
- row.row_id,
992
- payload,
993
- 'sqlite',
994
- ),
995
- );
996
- });
997
- await this.#db.batch(statements);
998
- const last = results[results.length - 1];
999
- if (last === undefined || results.length < BATCH) break;
1000
- afterPartition = last.partition;
1001
- afterRowId = last.row_id;
1002
- }
1003
- }
1004
-
1005
1369
  async begin(partition: string): Promise<StorageTransaction> {
1370
+ const tables = this.#tables;
1371
+ const db = this.#schemaDatabase();
1006
1372
  return new D1Transaction(
1007
- this.#db,
1373
+ db,
1008
1374
  partition,
1009
- (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
+ },
1010
1381
  this.#pushApplySerialized,
1011
1382
  );
1012
1383
  }
@@ -1023,6 +1394,7 @@ export class D1ServerStorage implements ServerStorage {
1023
1394
  partition: string,
1024
1395
  query: AuthoritativeQueryRequest,
1025
1396
  ): Promise<AuthoritativeQueryResult> {
1397
+ const db = this.#schemaDatabase();
1026
1398
  if (this.#tables === undefined) {
1027
1399
  throw new Error(
1028
1400
  'ensureSchema(schema) must run before registered queries',
@@ -1037,9 +1409,9 @@ export class D1ServerStorage implements ServerStorage {
1037
1409
  ),
1038
1410
  partition,
1039
1411
  );
1040
- const results = await this.#db.batch([
1041
- this.#db.prepare(prepared.sql).bind(...prepared.params),
1042
- this.#db
1412
+ const results = await db.batch([
1413
+ db.prepare(prepared.sql).bind(...prepared.params),
1414
+ db
1043
1415
  .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
1044
1416
  .bind(partition),
1045
1417
  ]);
@@ -1185,7 +1557,8 @@ export class D1ServerStorage implements ServerStorage {
1185
1557
  table: string,
1186
1558
  rowId: string,
1187
1559
  ): Promise<StoredRow | undefined> {
1188
- const record = await this.#db
1560
+ const db = this.#schemaDatabase();
1561
+ const record = await db
1189
1562
  .prepare(selectRowSql(this.table(table), 'sqlite'))
1190
1563
  .bind(partition, rowId)
1191
1564
  .first<SqliteRowRecord>();
@@ -1197,7 +1570,8 @@ export class D1ServerStorage implements ServerStorage {
1197
1570
  clientId: string,
1198
1571
  clientCommitId: string,
1199
1572
  ): Promise<StoredPushResult | undefined> {
1200
- const record = await this.#db
1573
+ const db = this.#schemaDatabase();
1574
+ const record = await db
1201
1575
  .prepare(
1202
1576
  'SELECT result FROM sync_push_results WHERE partition=? AND client_id=? AND client_commit_id=?',
1203
1577
  )
@@ -1428,6 +1802,7 @@ export class D1ServerStorage implements ServerStorage {
1428
1802
  partition: string,
1429
1803
  query: CommitWindowQuery,
1430
1804
  ): Promise<StoredCommit[]> {
1805
+ const db = this.#schemaDatabase();
1431
1806
  const variables = Object.keys(query.scopeFilter).sort();
1432
1807
  const firstVariable = variables[0];
1433
1808
  if (firstVariable === undefined) return [];
@@ -1443,7 +1818,7 @@ export class D1ServerStorage implements ServerStorage {
1443
1818
  let afterSeq = query.afterSeq;
1444
1819
  const batchSize = Math.max(64, query.limitChanges);
1445
1820
  while (deliveredChanges < query.limitChanges) {
1446
- const { results: records } = await this.#db
1821
+ const { results: records } = await db
1447
1822
  .prepare(sql)
1448
1823
  .bind(
1449
1824
  partition,
@@ -1473,6 +1848,7 @@ export class D1ServerStorage implements ServerStorage {
1473
1848
  }
1474
1849
 
1475
1850
  async scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]> {
1851
+ const db = this.#schemaDatabase();
1476
1852
  const firstVariable = assertScopeIndexedScan(query);
1477
1853
  const firstValues = query.scopeFilter[firstVariable] ?? [];
1478
1854
  if (firstValues.length === 0) return [];
@@ -1488,7 +1864,7 @@ export class D1ServerStorage implements ServerStorage {
1488
1864
  let afterRowId = query.afterRowId ?? '';
1489
1865
  const batchSize = Math.max(64, query.limit);
1490
1866
  while (rows.length < query.limit) {
1491
- const { results: records } = await this.#db
1867
+ const { results: records } = await db
1492
1868
  .prepare(sql)
1493
1869
  .bind(
1494
1870
  partition,
@@ -1520,6 +1896,7 @@ export class D1ServerStorage implements ServerStorage {
1520
1896
  partition: string,
1521
1897
  query: IndexRowScanQuery,
1522
1898
  ): Promise<StoredRow[]> {
1899
+ const db = this.#schemaDatabase();
1523
1900
  const table = this.table(query.table);
1524
1901
  const index = resolveIndexRowScan(table, query);
1525
1902
  const statement = indexRowPageStatement(
@@ -1531,7 +1908,7 @@ export class D1ServerStorage implements ServerStorage {
1531
1908
  query.limit,
1532
1909
  'sqlite',
1533
1910
  );
1534
- const { results } = await this.#db
1911
+ const { results } = await db
1535
1912
  .prepare(statement.sql)
1536
1913
  .bind(...statement.params)
1537
1914
  .all<SqliteRowRecord>();
@@ -1586,6 +1963,40 @@ export class D1ServerStorage implements ServerStorage {
1586
1963
  .run();
1587
1964
  }
1588
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
+
1589
2000
  async getActiveClientCursorFloor(
1590
2001
  partition: string,
1591
2002
  cutoffMs: number,
@@ -1623,7 +2034,8 @@ export class D1ServerStorage implements ServerStorage {
1623
2034
  readonly scopes: Record<string, string>;
1624
2035
  }[]
1625
2036
  > {
1626
- const { results: refs } = await this.#db
2037
+ const db = this.#schemaDatabase();
2038
+ const { results: refs } = await db
1627
2039
  .prepare(
1628
2040
  'SELECT tbl, row_id FROM sync_blob_refs WHERE partition=? AND blob_id=?',
1629
2041
  )
@@ -1637,7 +2049,7 @@ export class D1ServerStorage implements ServerStorage {
1637
2049
  for (const ref of refs) {
1638
2050
  const compiled = this.#tables?.get(ref.tbl);
1639
2051
  if (compiled === undefined) continue; // table no longer in the schema
1640
- const row = await this.#db
2052
+ const row = await db
1641
2053
  .prepare(selectRowScopesSql(compiled, 'sqlite'))
1642
2054
  .bind(partition, ref.row_id)
1643
2055
  .first<{ scopes: string }>();
@@ -1788,7 +2200,8 @@ export class D1ServerStorage implements ServerStorage {
1788
2200
  ): Promise<
1789
2201
  { serverVersion: number; scopes: Record<string, string> } | undefined
1790
2202
  > {
1791
- const record = await this.#db
2203
+ const db = this.#schemaDatabase();
2204
+ const record = await db
1792
2205
  .prepare(selectRowScopesSql(this.table(table), 'sqlite'))
1793
2206
  .bind(partition, rowId)
1794
2207
  .first<{ server_version: number; scopes: string }>();