@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.
- package/README.md +66 -9
- package/dist/admin.js +1 -5
- package/dist/authoritative-query.d.ts +14 -6
- package/dist/authoritative-query.js +60 -82
- package/dist/d1-storage.d.ts +13 -1
- package/dist/d1-storage.js +468 -121
- package/dist/operations.d.ts +2 -0
- package/dist/operations.js +23 -4
- package/dist/postgres-storage.d.ts +6 -1
- package/dist/postgres-storage.js +80 -24
- package/dist/prune.d.ts +3 -1
- package/dist/prune.js +18 -14
- package/dist/pull.js +79 -39
- package/dist/push.js +5 -5
- 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-bun.js +2 -2
- package/dist/sqlite-image.d.ts +3 -3
- package/dist/sqlite-image.js +10 -6
- package/dist/sqlite-node.js +2 -2
- package/dist/sqlite-storage.d.ts +6 -1
- package/dist/sqlite-storage.js +93 -32
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +7 -0
- package/dist/storage.d.ts +29 -5
- package/package.json +2 -2
- package/src/admin.ts +4 -6
- package/src/authoritative-query.ts +89 -94
- package/src/d1-storage.ts +641 -159
- package/src/operations.ts +31 -3
- package/src/postgres-storage.ts +146 -46
- package/src/prune.ts +29 -15
- package/src/pull.ts +102 -49
- package/src/push.ts +5 -5
- package/src/realtime.ts +20 -9
- package/src/relational-rows.ts +18 -2
- package/src/sqlite-bun.ts +2 -2
- package/src/sqlite-image.ts +26 -15
- package/src/sqlite-node.ts +2 -2
- package/src/sqlite-storage.ts +131 -37
- package/src/storage-errors.ts +22 -1
- 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.#
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
.
|
|
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
|
-
|
|
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
|
|
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 (
|
|
802
|
-
await this.migrate();
|
|
886
|
+
if (claim === null) {
|
|
803
887
|
const layouts = parseLayouts(marker?.layouts);
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
813
|
-
|
|
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(
|
|
1023
|
+
.prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
|
|
816
1024
|
.all<{ name: string; origin: string }>();
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
865
|
-
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
866
1202
|
}
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
1373
|
+
db,
|
|
1005
1374
|
partition,
|
|
1006
|
-
(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.
|
|
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
|
|
1038
|
-
|
|
1039
|
-
|
|
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(
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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(
|
|
1101
|
-
|
|
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(
|
|
1104
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 }>();
|