@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/dist/d1-storage.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
var _a;
|
|
2
|
+
import { validateCommitPruneQuery } from './prune.js';
|
|
3
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
2
4
|
/**
|
|
3
5
|
* Cloudflare D1 server storage for Workers deployments.
|
|
4
6
|
*
|
|
@@ -43,7 +45,7 @@ var _a;
|
|
|
43
45
|
import { decodeRow } from '@syncular/core';
|
|
44
46
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
45
47
|
import { syncError } from './errors.js';
|
|
46
|
-
import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
48
|
+
import { assertAppendOnlyMigration, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
47
49
|
import { matchesEffective } from './scopes.js';
|
|
48
50
|
import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
49
51
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
@@ -331,8 +333,14 @@ class D1Transaction {
|
|
|
331
333
|
row,
|
|
332
334
|
});
|
|
333
335
|
const p = this.#partition;
|
|
336
|
+
this.#buffer_(deleteSqliteRowScopesSql(compiled), [
|
|
337
|
+
p,
|
|
338
|
+
table,
|
|
339
|
+
row.rowId,
|
|
340
|
+
p,
|
|
341
|
+
row.rowId,
|
|
342
|
+
]);
|
|
334
343
|
this.#buffer_(upsertSql(compiled, 'sqlite'), upsertValues(compiled, p, row, 'sqlite'));
|
|
335
|
-
this.#buffer_('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?', [p, table, row.rowId]);
|
|
336
344
|
for (const [variable, value] of Object.entries(row.scopes)) {
|
|
337
345
|
this.#buffer_('INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)', [p, table, variable, value, row.rowId]);
|
|
338
346
|
}
|
|
@@ -341,11 +349,15 @@ class D1Transaction {
|
|
|
341
349
|
this.#assertOpen();
|
|
342
350
|
this.#pending.set(_a.#key(table, rowId), { kind: 'deleted' });
|
|
343
351
|
const p = this.#partition;
|
|
344
|
-
this.#
|
|
352
|
+
const compiled = this.#resolveTable(table);
|
|
353
|
+
this.#buffer_(deleteSqliteRowScopesSql(compiled), [
|
|
354
|
+
p,
|
|
355
|
+
table,
|
|
356
|
+
rowId,
|
|
345
357
|
p,
|
|
346
358
|
rowId,
|
|
347
359
|
]);
|
|
348
|
-
this.#buffer_(
|
|
360
|
+
this.#buffer_(deleteRowSql(compiled, 'sqlite'), [p, rowId]);
|
|
349
361
|
// §5.9.4: a deleted row references no blobs.
|
|
350
362
|
this.#buffer_('DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?', [p, table, rowId]);
|
|
351
363
|
}
|
|
@@ -495,79 +507,370 @@ export class D1ServerStorage {
|
|
|
495
507
|
return table;
|
|
496
508
|
}
|
|
497
509
|
async ensureSchema(schema) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// one marker read below when the version already matches.
|
|
501
|
-
if (this.#schemaVersion === schema.version)
|
|
510
|
+
if (this.#schemaVersion === schema.version) {
|
|
511
|
+
await this.#schemaDatabase().batch([]);
|
|
502
512
|
return;
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
513
|
+
}
|
|
514
|
+
if (!(await this.migrateSchema(schema)).complete) {
|
|
515
|
+
throw new StorageQueryError('sync.storage.schema_migration_pending');
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/** Run once per Worker invocation. Resume an incomplete result in another invocation. */
|
|
519
|
+
async migrateSchema(schema, options = {}) {
|
|
520
|
+
const maxStatements = options.maxStatements ?? 50;
|
|
521
|
+
if (!Number.isInteger(maxStatements) ||
|
|
522
|
+
maxStatements < 10 ||
|
|
523
|
+
maxStatements > 1000) {
|
|
524
|
+
throw new StorageQueryError('sync.storage.invalid_migration_budget');
|
|
525
|
+
}
|
|
526
|
+
const tables = [...schema.tables.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
527
|
+
for (const table of tables) {
|
|
528
|
+
if (tableColumnNames(table).length > D1_MAX_BIND_PARAMS) {
|
|
529
|
+
throw new Error('D1 caps statements at 100 bound parameters');
|
|
507
530
|
}
|
|
508
531
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
.
|
|
512
|
-
|
|
532
|
+
const target = JSON.stringify({
|
|
533
|
+
version: schema.version,
|
|
534
|
+
tables: tables.map((table) => ({
|
|
535
|
+
name: table.name,
|
|
536
|
+
columns: table.columns,
|
|
537
|
+
primaryKeyIndex: table.primaryKeyIndex,
|
|
538
|
+
materialize: table.materialize,
|
|
539
|
+
indexes: table.indexes,
|
|
540
|
+
scopes: table.scopePatterns,
|
|
541
|
+
})),
|
|
542
|
+
});
|
|
543
|
+
let statementsExecuted = 0;
|
|
544
|
+
const read = async (statement) => {
|
|
545
|
+
statementsExecuted++;
|
|
546
|
+
return statement.first();
|
|
547
|
+
};
|
|
548
|
+
const batch = async (statements) => {
|
|
549
|
+
statementsExecuted += statements.length;
|
|
550
|
+
return this.#db.batch(statements);
|
|
551
|
+
};
|
|
552
|
+
await batch([
|
|
553
|
+
this.#db.prepare(SCHEMA_META_DDL_SQLITE),
|
|
554
|
+
this.#db.prepare(`CREATE TABLE IF NOT EXISTS sync_schema_migration(
|
|
555
|
+
id INTEGER PRIMARY KEY CHECK(id=1),
|
|
556
|
+
target TEXT NOT NULL,
|
|
557
|
+
source_layouts TEXT NOT NULL,
|
|
558
|
+
state TEXT NOT NULL
|
|
559
|
+
)`),
|
|
560
|
+
]);
|
|
561
|
+
const marker = await read(this.#db.prepare('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1'));
|
|
562
|
+
let claim = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
|
|
563
|
+
if (claim === null && marker?.schema_version === schema.version) {
|
|
564
|
+
this.#tables = schema.tables;
|
|
565
|
+
this.#schemaVersion = schema.version;
|
|
566
|
+
return { complete: true, statementsExecuted };
|
|
567
|
+
}
|
|
513
568
|
if (marker !== null && marker.schema_version > schema.version) {
|
|
514
|
-
throw new
|
|
569
|
+
throw new StorageQueryError('sync.storage.schema_changed');
|
|
515
570
|
}
|
|
516
|
-
if (
|
|
517
|
-
await this.migrate();
|
|
571
|
+
if (claim === null) {
|
|
518
572
|
const layouts = parseLayouts(marker?.layouts);
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const escapedTableName = table.name.replaceAll('"', '""');
|
|
524
|
-
const { results } = await this.#db
|
|
525
|
-
.prepare(`PRAGMA table_info("${escapedTableName}")`)
|
|
526
|
-
.all();
|
|
527
|
-
if (results.length > 0) {
|
|
528
|
-
existing.set(table.name, new Set(results.map((c) => c.name)));
|
|
529
|
-
const indexes = await this.#db
|
|
530
|
-
.prepare(`PRAGMA index_list("${escapedTableName}")`)
|
|
531
|
-
.all();
|
|
532
|
-
existingIndexes.set(table.name, new Set(indexes.results
|
|
533
|
-
.filter((index) => index.origin === 'c')
|
|
534
|
-
.map((index) => index.name)));
|
|
573
|
+
for (const table of tables) {
|
|
574
|
+
const oldLayout = layouts[table.name];
|
|
575
|
+
if (oldLayout !== undefined) {
|
|
576
|
+
assertAppendOnlyMigration(table.name, oldLayout, table);
|
|
535
577
|
}
|
|
536
578
|
}
|
|
537
|
-
|
|
538
|
-
|
|
579
|
+
const state = {
|
|
580
|
+
phase: 'core',
|
|
581
|
+
offset: 0,
|
|
582
|
+
columns: {},
|
|
583
|
+
indexes: {},
|
|
584
|
+
rewrites: [],
|
|
585
|
+
ddl: [],
|
|
586
|
+
afterPartition: '',
|
|
587
|
+
afterRowId: '',
|
|
588
|
+
};
|
|
589
|
+
// Claim the database before inspecting projection layouts. The marker
|
|
590
|
+
// comparison prevents a delayed caller from planning against an old schema.
|
|
591
|
+
try {
|
|
592
|
+
await batch([
|
|
593
|
+
this.#db
|
|
594
|
+
.prepare(`INSERT INTO sync_schema_migration
|
|
595
|
+
SELECT 2, '', '', '' WHERE EXISTS (SELECT 1 FROM sync_schema_meta
|
|
596
|
+
WHERE id=1 AND schema_version<>?) OR (?=0 AND EXISTS (SELECT 1 FROM sync_schema_meta WHERE id=1))`)
|
|
597
|
+
.bind(marker?.schema_version ?? 0, marker === null ? 0 : 1),
|
|
598
|
+
this.#db
|
|
599
|
+
.prepare(`INSERT OR IGNORE INTO sync_schema_migration
|
|
600
|
+
(id, target, source_layouts, state) VALUES (1,?,?,?)`)
|
|
601
|
+
.bind(target, marker?.layouts ?? '{}', JSON.stringify(state)),
|
|
602
|
+
]);
|
|
539
603
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
for (const table of schema.tables.values()) {
|
|
546
|
-
const oldLayout = layouts[table.name];
|
|
547
|
-
const plan = rewritePlan(table, oldLayout, existing.get(table.name));
|
|
548
|
-
if (!plan.migrate && !plan.backfill)
|
|
549
|
-
continue;
|
|
550
|
-
await this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
|
|
604
|
+
catch (error) {
|
|
605
|
+
const current = await read(this.#db.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1'));
|
|
606
|
+
if (current?.schema_version !== marker?.schema_version)
|
|
607
|
+
return { complete: false, statementsExecuted };
|
|
608
|
+
throw error;
|
|
551
609
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
610
|
+
claim = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
|
|
611
|
+
}
|
|
612
|
+
if (claim === null || claim.target !== target) {
|
|
613
|
+
throw new StorageQueryError('sync.storage.schema_migration_conflict');
|
|
614
|
+
}
|
|
615
|
+
const sourceLayouts = parseLayouts(claim.source_layouts);
|
|
616
|
+
let state = JSON.parse(claim.state);
|
|
617
|
+
// Reserve one statement to distinguish a stale checkpoint from a database
|
|
618
|
+
// failure. A losing concurrent step returns incomplete without retrying here.
|
|
619
|
+
const save = async (next, statements, publish = false) => {
|
|
620
|
+
const prior = claim.state;
|
|
621
|
+
const encoded = JSON.stringify(next);
|
|
622
|
+
try {
|
|
623
|
+
await batch([
|
|
558
624
|
this.#db
|
|
559
|
-
.prepare(
|
|
560
|
-
|
|
561
|
-
|
|
625
|
+
.prepare(`INSERT INTO sync_schema_migration
|
|
626
|
+
SELECT 2, '', '', '' WHERE NOT EXISTS (
|
|
627
|
+
SELECT 1 FROM sync_schema_migration WHERE id=1 AND target=? AND state=?)`)
|
|
628
|
+
.bind(target, prior),
|
|
629
|
+
...statements,
|
|
630
|
+
publish
|
|
631
|
+
? this.#db.prepare('DELETE FROM sync_schema_migration WHERE id=1')
|
|
632
|
+
: this.#db
|
|
633
|
+
.prepare('UPDATE sync_schema_migration SET state=? WHERE id=1')
|
|
634
|
+
.bind(encoded),
|
|
635
|
+
]);
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
const current = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
|
|
639
|
+
if (current === null ||
|
|
640
|
+
(current.target === target && current.state !== prior))
|
|
641
|
+
return false;
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
claim.state = encoded;
|
|
645
|
+
state = next;
|
|
646
|
+
return true;
|
|
647
|
+
};
|
|
648
|
+
while (maxStatements - statementsExecuted >= 4) {
|
|
649
|
+
const remaining = maxStatements - statementsExecuted - 1;
|
|
650
|
+
if (state.phase === 'core') {
|
|
651
|
+
const ddl = sqliteDdlStatements();
|
|
652
|
+
const count = Math.min(ddl.length - state.offset, remaining - 2);
|
|
653
|
+
if (count > 0) {
|
|
654
|
+
const statements = ddl
|
|
655
|
+
.slice(state.offset, state.offset + count)
|
|
656
|
+
.map((sql) => this.#db.prepare(sql));
|
|
657
|
+
if (!(await save({ ...state, offset: state.offset + count }, statements)))
|
|
658
|
+
break;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (remaining < 4)
|
|
662
|
+
break;
|
|
663
|
+
statementsExecuted++;
|
|
664
|
+
const columns = await this.#db
|
|
665
|
+
.prepare('PRAGMA table_info("sync_clients")')
|
|
666
|
+
.all();
|
|
667
|
+
const needsWireVersion = !columns.results.some((column) => column.name === 'wire_version');
|
|
668
|
+
if (!(await save({ ...state, phase: 'prepare', offset: 0 }, needsWireVersion
|
|
669
|
+
? [
|
|
670
|
+
this.#db.prepare('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1'),
|
|
671
|
+
]
|
|
672
|
+
: [])))
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
else if (state.phase === 'prepare') {
|
|
676
|
+
const table = tables[state.offset];
|
|
677
|
+
if (table !== undefined) {
|
|
678
|
+
if (remaining < 4)
|
|
679
|
+
break;
|
|
680
|
+
statementsExecuted += 2;
|
|
681
|
+
const columns = await this.#db
|
|
682
|
+
.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
|
|
683
|
+
.all();
|
|
684
|
+
const indexes = await this.#db
|
|
685
|
+
.prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
|
|
686
|
+
.all();
|
|
687
|
+
// Validate the old codec before any application DDL or row rewrite.
|
|
688
|
+
rewritePlan(table, sourceLayouts[table.name], columns.results.length > 0
|
|
689
|
+
? new Set(columns.results.map((column) => column.name))
|
|
690
|
+
: undefined);
|
|
691
|
+
if (!(await save({
|
|
692
|
+
...state,
|
|
693
|
+
offset: state.offset + 1,
|
|
694
|
+
columns: {
|
|
695
|
+
...state.columns,
|
|
696
|
+
...(columns.results.length > 0
|
|
697
|
+
? {
|
|
698
|
+
[table.name]: columns.results.map((column) => column.name),
|
|
699
|
+
}
|
|
700
|
+
: {}),
|
|
701
|
+
},
|
|
702
|
+
indexes: {
|
|
703
|
+
...state.indexes,
|
|
704
|
+
[table.name]: indexes.results
|
|
705
|
+
.filter((index) => index.origin === 'c')
|
|
706
|
+
.map((index) => index.name),
|
|
707
|
+
},
|
|
708
|
+
}, [])))
|
|
709
|
+
break;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
const columns = new Map(Object.entries(state.columns).map(([name, names]) => [
|
|
713
|
+
name,
|
|
714
|
+
new Set(names),
|
|
715
|
+
]));
|
|
716
|
+
const indexes = new Map(Object.entries(state.indexes).map(([name, names]) => [
|
|
717
|
+
name,
|
|
718
|
+
new Set(names),
|
|
562
719
|
]));
|
|
720
|
+
const rewrites = tables.flatMap((table) => {
|
|
721
|
+
const plan = rewritePlan(table, sourceLayouts[table.name], columns.get(table.name));
|
|
722
|
+
return plan.migrate || plan.backfill
|
|
723
|
+
? [
|
|
724
|
+
{
|
|
725
|
+
table: table.name,
|
|
726
|
+
...(plan.migrate
|
|
727
|
+
? { oldLayout: sourceLayouts[table.name] }
|
|
728
|
+
: {}),
|
|
729
|
+
},
|
|
730
|
+
]
|
|
731
|
+
: [];
|
|
732
|
+
});
|
|
733
|
+
const ddl = [
|
|
734
|
+
...retiredTableNames(schema, sourceLayouts).flatMap((name) => [
|
|
735
|
+
`DELETE FROM sync_row_scopes WHERE tbl='${name.replaceAll("'", "''")}'`,
|
|
736
|
+
`DELETE FROM sync_blob_refs WHERE tbl='${name.replaceAll("'", "''")}'`,
|
|
737
|
+
dropTableDdl(name),
|
|
738
|
+
]),
|
|
739
|
+
...schemaDdl(schema, columns, 'sqlite', indexes),
|
|
740
|
+
];
|
|
741
|
+
if (!(await save({
|
|
742
|
+
...state,
|
|
743
|
+
phase: 'ddl',
|
|
744
|
+
offset: 0,
|
|
745
|
+
ddl,
|
|
746
|
+
rewrites,
|
|
747
|
+
columns: {},
|
|
748
|
+
indexes: {},
|
|
749
|
+
}, [])))
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
else if (state.phase === 'ddl') {
|
|
753
|
+
const count = Math.min(state.ddl.length - state.offset, remaining - 2);
|
|
754
|
+
if (count === 0) {
|
|
755
|
+
if (!(await save({ ...state, phase: 'rewrite', offset: 0, ddl: [] }, [])))
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
else if (!(await save({ ...state, offset: state.offset + count }, state.ddl
|
|
759
|
+
.slice(state.offset, state.offset + count)
|
|
760
|
+
.map((sql) => this.#db.prepare(sql)))))
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
const plan = state.rewrites[state.offset];
|
|
765
|
+
if (plan === undefined) {
|
|
766
|
+
if (!(await save(state, [
|
|
767
|
+
this.#db
|
|
768
|
+
.prepare(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1,?,?)
|
|
769
|
+
ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts`)
|
|
770
|
+
.bind(schema.version, layoutsOf(schema)),
|
|
771
|
+
], true)))
|
|
772
|
+
break;
|
|
773
|
+
this.#tables = schema.tables;
|
|
774
|
+
this.#schemaVersion = schema.version;
|
|
775
|
+
return { complete: true, statementsExecuted };
|
|
776
|
+
}
|
|
777
|
+
const table = schema.tables.get(plan.table);
|
|
778
|
+
if (table === undefined)
|
|
779
|
+
throw new StorageQueryError('sync.storage.schema_migration_conflict');
|
|
780
|
+
if (remaining < 4)
|
|
781
|
+
break;
|
|
782
|
+
const limit = Math.min(32, remaining - 3);
|
|
783
|
+
statementsExecuted++;
|
|
784
|
+
const { results } = await this.#db
|
|
785
|
+
.prepare(`SELECT * FROM (${selectRowsForRewriteSql(table, 'sqlite')})
|
|
786
|
+
WHERE EXISTS (SELECT 1 FROM sync_schema_migration WHERE id=1 AND target=? AND state=?)`)
|
|
787
|
+
.bind(state.afterPartition, state.afterRowId, limit, target, claim.state)
|
|
788
|
+
.all();
|
|
789
|
+
const statements = results.map((row) => {
|
|
790
|
+
const bytes = asUint8Array(row.payload);
|
|
791
|
+
const payload = plan.oldLayout !== undefined
|
|
792
|
+
? migratePayload(plan.oldLayout, table, bytes)
|
|
793
|
+
: bytes;
|
|
794
|
+
return this.#db
|
|
795
|
+
.prepare(rewriteRowSql(table, 'sqlite'))
|
|
796
|
+
.bind(...rewriteValues(table, row.partition, row.row_id, payload, 'sqlite'));
|
|
797
|
+
});
|
|
798
|
+
const last = results.at(-1);
|
|
799
|
+
const done = results.length < limit;
|
|
800
|
+
if (!(await save({
|
|
801
|
+
...state,
|
|
802
|
+
offset: state.offset + (done ? 1 : 0),
|
|
803
|
+
afterPartition: done ? '' : last.partition,
|
|
804
|
+
afterRowId: done ? '' : last.row_id,
|
|
805
|
+
}, statements)))
|
|
806
|
+
break;
|
|
563
807
|
}
|
|
564
|
-
await this.#db
|
|
565
|
-
.prepare('INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts')
|
|
566
|
-
.bind(schema.version, layoutsOf(schema))
|
|
567
|
-
.run();
|
|
568
808
|
}
|
|
569
|
-
|
|
570
|
-
|
|
809
|
+
return { complete: false, statementsExecuted };
|
|
810
|
+
}
|
|
811
|
+
/** Every protected read or write shares a transaction with its schema check. */
|
|
812
|
+
#schemaDatabase() {
|
|
813
|
+
if (this.#schemaVersion === undefined)
|
|
814
|
+
throw new StorageQueryError('sync.storage.schema_changed');
|
|
815
|
+
const db = this.#db;
|
|
816
|
+
const version = this.#schemaVersion;
|
|
817
|
+
const sources = new WeakMap();
|
|
818
|
+
const execute = async (statements) => {
|
|
819
|
+
try {
|
|
820
|
+
const results = await db.batch([
|
|
821
|
+
db
|
|
822
|
+
.prepare(`INSERT INTO sync_schema_migration SELECT 2, '', '', ''
|
|
823
|
+
WHERE EXISTS (SELECT 1 FROM sync_schema_migration WHERE id=1)
|
|
824
|
+
OR NOT EXISTS (SELECT 1 FROM sync_schema_meta WHERE id=1 AND schema_version=?)`)
|
|
825
|
+
.bind(version),
|
|
826
|
+
...statements,
|
|
827
|
+
]);
|
|
828
|
+
return results.slice(1);
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
const migration = await db
|
|
832
|
+
.prepare('SELECT id FROM sync_schema_migration WHERE id=1')
|
|
833
|
+
.first();
|
|
834
|
+
if (migration !== null)
|
|
835
|
+
throw new StorageQueryError('sync.storage.schema_migration_pending');
|
|
836
|
+
const marker = await db
|
|
837
|
+
.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
|
|
838
|
+
.first();
|
|
839
|
+
if (marker?.schema_version !== version)
|
|
840
|
+
throw new StorageQueryError('sync.storage.schema_changed');
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
const prepare = (sql, params = []) => {
|
|
845
|
+
const source = db.prepare(sql).bind(...params);
|
|
846
|
+
const all = async () => {
|
|
847
|
+
const result = (await execute([source]))[0];
|
|
848
|
+
if (typeof result !== 'object' ||
|
|
849
|
+
result === null ||
|
|
850
|
+
!('results' in result) ||
|
|
851
|
+
!Array.isArray(result.results)) {
|
|
852
|
+
throw new Error('D1 query returned an invalid batch result');
|
|
853
|
+
}
|
|
854
|
+
return { results: result.results };
|
|
855
|
+
};
|
|
856
|
+
const statement = {
|
|
857
|
+
bind: (...values) => prepare(sql, values),
|
|
858
|
+
all,
|
|
859
|
+
first: async () => (await all()).results[0] ?? null,
|
|
860
|
+
run: async () => (await execute([source]))[0],
|
|
861
|
+
};
|
|
862
|
+
sources.set(statement, source);
|
|
863
|
+
return statement;
|
|
864
|
+
};
|
|
865
|
+
return {
|
|
866
|
+
prepare,
|
|
867
|
+
batch: (statements) => execute(statements.map((statement) => {
|
|
868
|
+
const source = sources.get(statement);
|
|
869
|
+
if (source === undefined)
|
|
870
|
+
throw new Error('D1 batch contains a foreign statement');
|
|
871
|
+
return source;
|
|
872
|
+
})),
|
|
873
|
+
};
|
|
571
874
|
}
|
|
572
875
|
async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
|
|
573
876
|
if (initialLogEpoch.length === 0) {
|
|
@@ -631,39 +934,15 @@ export class D1ServerStorage {
|
|
|
631
934
|
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
632
935
|
}));
|
|
633
936
|
}
|
|
634
|
-
/** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
|
|
635
|
-
async #rewriteRows(table, oldLayout) {
|
|
636
|
-
const select = selectRowsForRewriteSql(table, 'sqlite');
|
|
637
|
-
const update = rewriteRowSql(table, 'sqlite');
|
|
638
|
-
const BATCH = 500;
|
|
639
|
-
let afterPartition = '';
|
|
640
|
-
let afterRowId = '';
|
|
641
|
-
for (;;) {
|
|
642
|
-
const { results } = await this.#db
|
|
643
|
-
.prepare(select)
|
|
644
|
-
.bind(afterPartition, afterRowId, BATCH)
|
|
645
|
-
.all();
|
|
646
|
-
if (results.length === 0)
|
|
647
|
-
break;
|
|
648
|
-
const statements = results.map((row) => {
|
|
649
|
-
const bytes = asUint8Array(row.payload);
|
|
650
|
-
const payload = oldLayout !== undefined
|
|
651
|
-
? migratePayload(oldLayout, table, bytes)
|
|
652
|
-
: bytes;
|
|
653
|
-
return this.#db
|
|
654
|
-
.prepare(update)
|
|
655
|
-
.bind(...rewriteValues(table, row.partition, row.row_id, payload, 'sqlite'));
|
|
656
|
-
});
|
|
657
|
-
await this.#db.batch(statements);
|
|
658
|
-
const last = results[results.length - 1];
|
|
659
|
-
if (last === undefined || results.length < BATCH)
|
|
660
|
-
break;
|
|
661
|
-
afterPartition = last.partition;
|
|
662
|
-
afterRowId = last.row_id;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
937
|
async begin(partition) {
|
|
666
|
-
|
|
938
|
+
const tables = this.#tables;
|
|
939
|
+
const db = this.#schemaDatabase();
|
|
940
|
+
return new D1Transaction(db, partition, (name) => {
|
|
941
|
+
const table = tables?.get(name);
|
|
942
|
+
if (table === undefined)
|
|
943
|
+
throw new StorageQueryError('sync.storage.schema_changed');
|
|
944
|
+
return table;
|
|
945
|
+
}, this.#pushApplySerialized);
|
|
667
946
|
}
|
|
668
947
|
async getMaxCommitSeq(partition) {
|
|
669
948
|
const row = await this.#db
|
|
@@ -673,13 +952,14 @@ export class D1ServerStorage {
|
|
|
673
952
|
return row?.max_commit_seq ?? 0;
|
|
674
953
|
}
|
|
675
954
|
async queryAuthoritative(partition, query) {
|
|
955
|
+
const db = this.#schemaDatabase();
|
|
676
956
|
if (this.#tables === undefined) {
|
|
677
957
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
678
958
|
}
|
|
679
|
-
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.
|
|
680
|
-
const results = await
|
|
681
|
-
|
|
682
|
-
|
|
959
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
960
|
+
const results = await db.batch([
|
|
961
|
+
db.prepare(prepared.sql).bind(...prepared.params),
|
|
962
|
+
db
|
|
683
963
|
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
684
964
|
.bind(partition),
|
|
685
965
|
]);
|
|
@@ -707,6 +987,13 @@ export class D1ServerStorage {
|
|
|
707
987
|
maxCommitSeq,
|
|
708
988
|
};
|
|
709
989
|
}
|
|
990
|
+
async getPartitionLogEpoch(partition) {
|
|
991
|
+
const row = await this.#db
|
|
992
|
+
.prepare('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
|
|
993
|
+
.bind(partition)
|
|
994
|
+
.first();
|
|
995
|
+
return row?.log_epoch;
|
|
996
|
+
}
|
|
710
997
|
async getHorizonSeq(partition) {
|
|
711
998
|
const row = await this.#db
|
|
712
999
|
.prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
@@ -716,27 +1003,55 @@ export class D1ServerStorage {
|
|
|
716
1003
|
}
|
|
717
1004
|
async setHorizonSeq(partition, seq) {
|
|
718
1005
|
await this.#db
|
|
719
|
-
.prepare('INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq')
|
|
1006
|
+
.prepare('INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)')
|
|
720
1007
|
.bind(partition, seq)
|
|
721
1008
|
.run();
|
|
722
1009
|
}
|
|
723
|
-
async pruneCommitsThrough(partition,
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1010
|
+
async pruneCommitsThrough(partition, query) {
|
|
1011
|
+
validateCommitPruneQuery(query);
|
|
1012
|
+
if (!this.#pushApplySerialized)
|
|
1013
|
+
throw new Error('D1 pruning requires externally serialized partition writes');
|
|
1014
|
+
const epochGuard = 'EXISTS (SELECT 1 FROM sync_partition_registry WHERE partition=? AND log_epoch=?)';
|
|
1015
|
+
const horizon = '(SELECT horizon_seq FROM sync_partitions WHERE partition=?)';
|
|
1016
|
+
const results = await this.#db.batch([
|
|
729
1017
|
this.#db
|
|
730
|
-
.prepare(
|
|
731
|
-
.bind(partition,
|
|
1018
|
+
.prepare(`SELECT log_epoch, coalesce(${horizon},0) AS previous_horizon_seq FROM sync_partition_registry WHERE partition=?`)
|
|
1019
|
+
.bind(partition, partition),
|
|
732
1020
|
this.#db
|
|
733
|
-
.prepare(
|
|
734
|
-
|
|
1021
|
+
.prepare(`INSERT INTO sync_partitions(partition,horizon_seq) SELECT ?,? WHERE ${epochGuard}
|
|
1022
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
|
|
1023
|
+
.bind(partition, query.throughSeq, partition, query.logEpoch),
|
|
735
1024
|
this.#db
|
|
736
|
-
.prepare(
|
|
737
|
-
.bind(partition,
|
|
1025
|
+
.prepare(`SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`)
|
|
1026
|
+
.bind(partition, partition, partition),
|
|
1027
|
+
...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) => this.#db
|
|
1028
|
+
.prepare(`DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`)
|
|
1029
|
+
.bind(partition, partition, partition, query.logEpoch)),
|
|
738
1030
|
]);
|
|
739
|
-
|
|
1031
|
+
const [before, after] = [results[0], results[2]].map((result) => {
|
|
1032
|
+
if (typeof result !== 'object' ||
|
|
1033
|
+
result === null ||
|
|
1034
|
+
!('results' in result) ||
|
|
1035
|
+
!Array.isArray(result.results)) {
|
|
1036
|
+
throw new Error('D1 pruning returned an invalid batch result');
|
|
1037
|
+
}
|
|
1038
|
+
const row = result.results[0];
|
|
1039
|
+
return typeof row === 'object' && row !== null
|
|
1040
|
+
? row
|
|
1041
|
+
: undefined;
|
|
1042
|
+
});
|
|
1043
|
+
if (before?.log_epoch !== query.logEpoch)
|
|
1044
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
1045
|
+
if (typeof before.previous_horizon_seq !== 'number' ||
|
|
1046
|
+
typeof after?.horizon_seq !== 'number' ||
|
|
1047
|
+
typeof after.removed_commits !== 'number') {
|
|
1048
|
+
throw new Error('D1 pruning returned invalid horizon metadata');
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
previousHorizonSeq: before.previous_horizon_seq,
|
|
1052
|
+
horizonSeq: after.horizon_seq,
|
|
1053
|
+
removedCommits: after.removed_commits,
|
|
1054
|
+
};
|
|
740
1055
|
}
|
|
741
1056
|
async getCommitSeqBefore(partition, createdBeforeMs) {
|
|
742
1057
|
const row = await this.#db
|
|
@@ -746,14 +1061,16 @@ export class D1ServerStorage {
|
|
|
746
1061
|
return row?.seq ?? 0;
|
|
747
1062
|
}
|
|
748
1063
|
async getRow(partition, table, rowId) {
|
|
749
|
-
const
|
|
1064
|
+
const db = this.#schemaDatabase();
|
|
1065
|
+
const record = await db
|
|
750
1066
|
.prepare(selectRowSql(this.table(table), 'sqlite'))
|
|
751
1067
|
.bind(partition, rowId)
|
|
752
1068
|
.first();
|
|
753
1069
|
return record === null ? undefined : toStoredRow(record);
|
|
754
1070
|
}
|
|
755
1071
|
async getPushResult(partition, clientId, clientCommitId) {
|
|
756
|
-
const
|
|
1072
|
+
const db = this.#schemaDatabase();
|
|
1073
|
+
const record = await db
|
|
757
1074
|
.prepare('SELECT result FROM sync_push_results WHERE partition=? AND client_id=? AND client_commit_id=?')
|
|
758
1075
|
.bind(partition, clientId, clientCommitId)
|
|
759
1076
|
.first();
|
|
@@ -899,6 +1216,7 @@ export class D1ServerStorage {
|
|
|
899
1216
|
};
|
|
900
1217
|
}
|
|
901
1218
|
async readCommitWindow(partition, query) {
|
|
1219
|
+
const db = this.#schemaDatabase();
|
|
902
1220
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
903
1221
|
const firstVariable = variables[0];
|
|
904
1222
|
if (firstVariable === undefined)
|
|
@@ -916,7 +1234,7 @@ export class D1ServerStorage {
|
|
|
916
1234
|
let afterSeq = query.afterSeq;
|
|
917
1235
|
const batchSize = Math.max(64, query.limitChanges);
|
|
918
1236
|
while (deliveredChanges < query.limitChanges) {
|
|
919
|
-
const { results: records } = await
|
|
1237
|
+
const { results: records } = await db
|
|
920
1238
|
.prepare(sql)
|
|
921
1239
|
.bind(partition, query.table, firstVariable, ...firstValues, afterSeq, query.throughSeq, batchSize, partition, partition, query.table)
|
|
922
1240
|
.all();
|
|
@@ -932,6 +1250,7 @@ export class D1ServerStorage {
|
|
|
932
1250
|
return commits;
|
|
933
1251
|
}
|
|
934
1252
|
async scanRows(partition, query) {
|
|
1253
|
+
const db = this.#schemaDatabase();
|
|
935
1254
|
const firstVariable = assertScopeIndexedScan(query);
|
|
936
1255
|
const firstValues = query.scopeFilter[firstVariable] ?? [];
|
|
937
1256
|
if (firstValues.length === 0)
|
|
@@ -944,7 +1263,7 @@ export class D1ServerStorage {
|
|
|
944
1263
|
let afterRowId = query.afterRowId ?? '';
|
|
945
1264
|
const batchSize = Math.max(64, query.limit);
|
|
946
1265
|
while (rows.length < query.limit) {
|
|
947
|
-
const { results: records } = await
|
|
1266
|
+
const { results: records } = await db
|
|
948
1267
|
.prepare(sql)
|
|
949
1268
|
.bind(partition, query.table, firstVariable, ...firstValues, afterRowId, batchSize, partition)
|
|
950
1269
|
.all();
|
|
@@ -969,10 +1288,11 @@ export class D1ServerStorage {
|
|
|
969
1288
|
return rows;
|
|
970
1289
|
}
|
|
971
1290
|
async scanRowsByIndex(partition, query) {
|
|
1291
|
+
const db = this.#schemaDatabase();
|
|
972
1292
|
const table = this.table(query.table);
|
|
973
1293
|
const index = resolveIndexRowScan(table, query);
|
|
974
1294
|
const statement = indexRowPageStatement(table, index, query.values, partition, query.afterRowId, query.limit, 'sqlite');
|
|
975
|
-
const { results } = await
|
|
1295
|
+
const { results } = await db
|
|
976
1296
|
.prepare(statement.sql)
|
|
977
1297
|
.bind(...statement.params)
|
|
978
1298
|
.all();
|
|
@@ -1000,6 +1320,31 @@ export class D1ServerStorage {
|
|
|
1000
1320
|
.bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
|
|
1001
1321
|
.run();
|
|
1002
1322
|
}
|
|
1323
|
+
async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
|
|
1324
|
+
await this.#db
|
|
1325
|
+
.prepare(`UPDATE sync_clients
|
|
1326
|
+
SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
|
|
1327
|
+
WHERE partition=? AND client_id=? AND actor_id=?
|
|
1328
|
+
AND EXISTS (SELECT 1 FROM sync_partition_registry
|
|
1329
|
+
WHERE partition=sync_clients.partition AND log_epoch=?)`)
|
|
1330
|
+
.bind(cursor, updatedAtMs, partition, clientId, actorId, logEpoch)
|
|
1331
|
+
.run();
|
|
1332
|
+
}
|
|
1333
|
+
async updateClientCursor(partition, clientId, cursor, updatedAtMs) {
|
|
1334
|
+
await this.#db
|
|
1335
|
+
.prepare(`UPDATE sync_clients
|
|
1336
|
+
SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
|
|
1337
|
+
WHERE partition=? AND client_id=?`)
|
|
1338
|
+
.bind(cursor, updatedAtMs, partition, clientId)
|
|
1339
|
+
.run();
|
|
1340
|
+
}
|
|
1341
|
+
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
1342
|
+
const row = await this.#db
|
|
1343
|
+
.prepare('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
|
|
1344
|
+
.bind(partition, cutoffMs)
|
|
1345
|
+
.first();
|
|
1346
|
+
return row.cursor;
|
|
1347
|
+
}
|
|
1003
1348
|
async listClientCursors(partition) {
|
|
1004
1349
|
const { results } = await this.#db
|
|
1005
1350
|
.prepare('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=?')
|
|
@@ -1012,7 +1357,8 @@ export class D1ServerStorage {
|
|
|
1012
1357
|
}));
|
|
1013
1358
|
}
|
|
1014
1359
|
async listRowsReferencingBlob(partition, blobId) {
|
|
1015
|
-
const
|
|
1360
|
+
const db = this.#schemaDatabase();
|
|
1361
|
+
const { results: refs } = await db
|
|
1016
1362
|
.prepare('SELECT tbl, row_id FROM sync_blob_refs WHERE partition=? AND blob_id=?')
|
|
1017
1363
|
.bind(partition, blobId)
|
|
1018
1364
|
.all();
|
|
@@ -1021,7 +1367,7 @@ export class D1ServerStorage {
|
|
|
1021
1367
|
const compiled = this.#tables?.get(ref.tbl);
|
|
1022
1368
|
if (compiled === undefined)
|
|
1023
1369
|
continue; // table no longer in the schema
|
|
1024
|
-
const row = await
|
|
1370
|
+
const row = await db
|
|
1025
1371
|
.prepare(selectRowScopesSql(compiled, 'sqlite'))
|
|
1026
1372
|
.bind(partition, ref.row_id)
|
|
1027
1373
|
.first();
|
|
@@ -1123,7 +1469,8 @@ export class D1ServerStorage {
|
|
|
1123
1469
|
return out;
|
|
1124
1470
|
}
|
|
1125
1471
|
async getRowScopes(partition, table, rowId) {
|
|
1126
|
-
const
|
|
1472
|
+
const db = this.#schemaDatabase();
|
|
1473
|
+
const record = await db
|
|
1127
1474
|
.prepare(selectRowScopesSql(this.table(table), 'sqlite'))
|
|
1128
1475
|
.bind(partition, rowId)
|
|
1129
1476
|
.first();
|