@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/README.md +25 -5
- package/dist/d1-storage.d.ts +9 -0
- package/dist/d1-storage.js +409 -106
- package/dist/postgres-storage.d.ts +2 -0
- package/dist/postgres-storage.js +41 -14
- package/dist/pull.js +16 -7
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +16 -10
- package/dist/relational-rows.d.ts +7 -1
- package/dist/relational-rows.js +17 -2
- package/dist/sqlite-storage.d.ts +2 -0
- package/dist/sqlite-storage.js +26 -6
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +9 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +555 -142
- package/src/postgres-storage.ts +64 -21
- package/src/pull.ts +22 -7
- package/src/realtime.ts +20 -9
- package/src/relational-rows.ts +18 -2
- package/src/sqlite-storage.ts +45 -9
- package/src/storage-errors.ts +12 -0
- package/src/storage.ts +21 -0
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.#
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
.
|
|
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
|
-
|
|
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
|
|
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 (
|
|
805
|
-
await this.migrate();
|
|
886
|
+
if (claim === null) {
|
|
806
887
|
const layouts = parseLayouts(marker?.layouts);
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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(
|
|
1023
|
+
.prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
|
|
819
1024
|
.all<{ name: string; origin: string }>();
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
868
|
-
|
|
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
|
-
|
|
871
|
-
|
|
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
|
-
|
|
1373
|
+
db,
|
|
1008
1374
|
partition,
|
|
1009
|
-
(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
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 }>();
|