@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/dist/d1-storage.js
CHANGED
|
@@ -45,7 +45,7 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
45
45
|
import { decodeRow } from '@syncular/core';
|
|
46
46
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
47
47
|
import { syncError } from './errors.js';
|
|
48
|
-
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';
|
|
49
49
|
import { matchesEffective } from './scopes.js';
|
|
50
50
|
import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
51
51
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
@@ -333,8 +333,14 @@ class D1Transaction {
|
|
|
333
333
|
row,
|
|
334
334
|
});
|
|
335
335
|
const p = this.#partition;
|
|
336
|
+
this.#buffer_(deleteSqliteRowScopesSql(compiled), [
|
|
337
|
+
p,
|
|
338
|
+
table,
|
|
339
|
+
row.rowId,
|
|
340
|
+
p,
|
|
341
|
+
row.rowId,
|
|
342
|
+
]);
|
|
336
343
|
this.#buffer_(upsertSql(compiled, 'sqlite'), upsertValues(compiled, p, row, 'sqlite'));
|
|
337
|
-
this.#buffer_('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?', [p, table, row.rowId]);
|
|
338
344
|
for (const [variable, value] of Object.entries(row.scopes)) {
|
|
339
345
|
this.#buffer_('INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)', [p, table, variable, value, row.rowId]);
|
|
340
346
|
}
|
|
@@ -343,11 +349,15 @@ class D1Transaction {
|
|
|
343
349
|
this.#assertOpen();
|
|
344
350
|
this.#pending.set(_a.#key(table, rowId), { kind: 'deleted' });
|
|
345
351
|
const p = this.#partition;
|
|
346
|
-
this.#
|
|
352
|
+
const compiled = this.#resolveTable(table);
|
|
353
|
+
this.#buffer_(deleteSqliteRowScopesSql(compiled), [
|
|
354
|
+
p,
|
|
355
|
+
table,
|
|
356
|
+
rowId,
|
|
347
357
|
p,
|
|
348
358
|
rowId,
|
|
349
359
|
]);
|
|
350
|
-
this.#buffer_(
|
|
360
|
+
this.#buffer_(deleteRowSql(compiled, 'sqlite'), [p, rowId]);
|
|
351
361
|
// §5.9.4: a deleted row references no blobs.
|
|
352
362
|
this.#buffer_('DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?', [p, table, rowId]);
|
|
353
363
|
}
|
|
@@ -497,79 +507,370 @@ export class D1ServerStorage {
|
|
|
497
507
|
return table;
|
|
498
508
|
}
|
|
499
509
|
async ensureSchema(schema) {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
// one marker read below when the version already matches.
|
|
503
|
-
if (this.#schemaVersion === schema.version)
|
|
510
|
+
if (this.#schemaVersion === schema.version) {
|
|
511
|
+
await this.#schemaDatabase().batch([]);
|
|
504
512
|
return;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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');
|
|
509
530
|
}
|
|
510
531
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
.
|
|
514
|
-
|
|
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
|
+
}
|
|
515
568
|
if (marker !== null && marker.schema_version > schema.version) {
|
|
516
|
-
throw new
|
|
569
|
+
throw new StorageQueryError('sync.storage.schema_changed');
|
|
517
570
|
}
|
|
518
|
-
if (
|
|
519
|
-
await this.migrate();
|
|
571
|
+
if (claim === null) {
|
|
520
572
|
const layouts = parseLayouts(marker?.layouts);
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const escapedTableName = table.name.replaceAll('"', '""');
|
|
526
|
-
const { results } = await this.#db
|
|
527
|
-
.prepare(`PRAGMA table_info("${escapedTableName}")`)
|
|
528
|
-
.all();
|
|
529
|
-
if (results.length > 0) {
|
|
530
|
-
existing.set(table.name, new Set(results.map((c) => c.name)));
|
|
531
|
-
const indexes = await this.#db
|
|
532
|
-
.prepare(`PRAGMA index_list("${escapedTableName}")`)
|
|
533
|
-
.all();
|
|
534
|
-
existingIndexes.set(table.name, new Set(indexes.results
|
|
535
|
-
.filter((index) => index.origin === 'c')
|
|
536
|
-
.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);
|
|
537
577
|
}
|
|
538
578
|
}
|
|
539
|
-
|
|
540
|
-
|
|
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
|
+
]);
|
|
541
603
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
for (const table of schema.tables.values()) {
|
|
548
|
-
const oldLayout = layouts[table.name];
|
|
549
|
-
const plan = rewritePlan(table, oldLayout, existing.get(table.name));
|
|
550
|
-
if (!plan.migrate && !plan.backfill)
|
|
551
|
-
continue;
|
|
552
|
-
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;
|
|
553
609
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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([
|
|
560
624
|
this.#db
|
|
561
|
-
.prepare(
|
|
562
|
-
|
|
563
|
-
|
|
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),
|
|
564
715
|
]));
|
|
716
|
+
const indexes = new Map(Object.entries(state.indexes).map(([name, names]) => [
|
|
717
|
+
name,
|
|
718
|
+
new Set(names),
|
|
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;
|
|
565
807
|
}
|
|
566
|
-
await this.#db
|
|
567
|
-
.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')
|
|
568
|
-
.bind(schema.version, layoutsOf(schema))
|
|
569
|
-
.run();
|
|
570
808
|
}
|
|
571
|
-
|
|
572
|
-
|
|
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
|
+
};
|
|
573
874
|
}
|
|
574
875
|
async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
|
|
575
876
|
if (initialLogEpoch.length === 0) {
|
|
@@ -633,39 +934,15 @@ export class D1ServerStorage {
|
|
|
633
934
|
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
634
935
|
}));
|
|
635
936
|
}
|
|
636
|
-
/** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
|
|
637
|
-
async #rewriteRows(table, oldLayout) {
|
|
638
|
-
const select = selectRowsForRewriteSql(table, 'sqlite');
|
|
639
|
-
const update = rewriteRowSql(table, 'sqlite');
|
|
640
|
-
const BATCH = 500;
|
|
641
|
-
let afterPartition = '';
|
|
642
|
-
let afterRowId = '';
|
|
643
|
-
for (;;) {
|
|
644
|
-
const { results } = await this.#db
|
|
645
|
-
.prepare(select)
|
|
646
|
-
.bind(afterPartition, afterRowId, BATCH)
|
|
647
|
-
.all();
|
|
648
|
-
if (results.length === 0)
|
|
649
|
-
break;
|
|
650
|
-
const statements = results.map((row) => {
|
|
651
|
-
const bytes = asUint8Array(row.payload);
|
|
652
|
-
const payload = oldLayout !== undefined
|
|
653
|
-
? migratePayload(oldLayout, table, bytes)
|
|
654
|
-
: bytes;
|
|
655
|
-
return this.#db
|
|
656
|
-
.prepare(update)
|
|
657
|
-
.bind(...rewriteValues(table, row.partition, row.row_id, payload, 'sqlite'));
|
|
658
|
-
});
|
|
659
|
-
await this.#db.batch(statements);
|
|
660
|
-
const last = results[results.length - 1];
|
|
661
|
-
if (last === undefined || results.length < BATCH)
|
|
662
|
-
break;
|
|
663
|
-
afterPartition = last.partition;
|
|
664
|
-
afterRowId = last.row_id;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
937
|
async begin(partition) {
|
|
668
|
-
|
|
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);
|
|
669
946
|
}
|
|
670
947
|
async getMaxCommitSeq(partition) {
|
|
671
948
|
const row = await this.#db
|
|
@@ -675,13 +952,14 @@ export class D1ServerStorage {
|
|
|
675
952
|
return row?.max_commit_seq ?? 0;
|
|
676
953
|
}
|
|
677
954
|
async queryAuthoritative(partition, query) {
|
|
955
|
+
const db = this.#schemaDatabase();
|
|
678
956
|
if (this.#tables === undefined) {
|
|
679
957
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
680
958
|
}
|
|
681
959
|
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
682
|
-
const results = await
|
|
683
|
-
|
|
684
|
-
|
|
960
|
+
const results = await db.batch([
|
|
961
|
+
db.prepare(prepared.sql).bind(...prepared.params),
|
|
962
|
+
db
|
|
685
963
|
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
686
964
|
.bind(partition),
|
|
687
965
|
]);
|
|
@@ -783,14 +1061,16 @@ export class D1ServerStorage {
|
|
|
783
1061
|
return row?.seq ?? 0;
|
|
784
1062
|
}
|
|
785
1063
|
async getRow(partition, table, rowId) {
|
|
786
|
-
const
|
|
1064
|
+
const db = this.#schemaDatabase();
|
|
1065
|
+
const record = await db
|
|
787
1066
|
.prepare(selectRowSql(this.table(table), 'sqlite'))
|
|
788
1067
|
.bind(partition, rowId)
|
|
789
1068
|
.first();
|
|
790
1069
|
return record === null ? undefined : toStoredRow(record);
|
|
791
1070
|
}
|
|
792
1071
|
async getPushResult(partition, clientId, clientCommitId) {
|
|
793
|
-
const
|
|
1072
|
+
const db = this.#schemaDatabase();
|
|
1073
|
+
const record = await db
|
|
794
1074
|
.prepare('SELECT result FROM sync_push_results WHERE partition=? AND client_id=? AND client_commit_id=?')
|
|
795
1075
|
.bind(partition, clientId, clientCommitId)
|
|
796
1076
|
.first();
|
|
@@ -936,6 +1216,7 @@ export class D1ServerStorage {
|
|
|
936
1216
|
};
|
|
937
1217
|
}
|
|
938
1218
|
async readCommitWindow(partition, query) {
|
|
1219
|
+
const db = this.#schemaDatabase();
|
|
939
1220
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
940
1221
|
const firstVariable = variables[0];
|
|
941
1222
|
if (firstVariable === undefined)
|
|
@@ -953,7 +1234,7 @@ export class D1ServerStorage {
|
|
|
953
1234
|
let afterSeq = query.afterSeq;
|
|
954
1235
|
const batchSize = Math.max(64, query.limitChanges);
|
|
955
1236
|
while (deliveredChanges < query.limitChanges) {
|
|
956
|
-
const { results: records } = await
|
|
1237
|
+
const { results: records } = await db
|
|
957
1238
|
.prepare(sql)
|
|
958
1239
|
.bind(partition, query.table, firstVariable, ...firstValues, afterSeq, query.throughSeq, batchSize, partition, partition, query.table)
|
|
959
1240
|
.all();
|
|
@@ -969,6 +1250,7 @@ export class D1ServerStorage {
|
|
|
969
1250
|
return commits;
|
|
970
1251
|
}
|
|
971
1252
|
async scanRows(partition, query) {
|
|
1253
|
+
const db = this.#schemaDatabase();
|
|
972
1254
|
const firstVariable = assertScopeIndexedScan(query);
|
|
973
1255
|
const firstValues = query.scopeFilter[firstVariable] ?? [];
|
|
974
1256
|
if (firstValues.length === 0)
|
|
@@ -981,7 +1263,7 @@ export class D1ServerStorage {
|
|
|
981
1263
|
let afterRowId = query.afterRowId ?? '';
|
|
982
1264
|
const batchSize = Math.max(64, query.limit);
|
|
983
1265
|
while (rows.length < query.limit) {
|
|
984
|
-
const { results: records } = await
|
|
1266
|
+
const { results: records } = await db
|
|
985
1267
|
.prepare(sql)
|
|
986
1268
|
.bind(partition, query.table, firstVariable, ...firstValues, afterRowId, batchSize, partition)
|
|
987
1269
|
.all();
|
|
@@ -1006,10 +1288,11 @@ export class D1ServerStorage {
|
|
|
1006
1288
|
return rows;
|
|
1007
1289
|
}
|
|
1008
1290
|
async scanRowsByIndex(partition, query) {
|
|
1291
|
+
const db = this.#schemaDatabase();
|
|
1009
1292
|
const table = this.table(query.table);
|
|
1010
1293
|
const index = resolveIndexRowScan(table, query);
|
|
1011
1294
|
const statement = indexRowPageStatement(table, index, query.values, partition, query.afterRowId, query.limit, 'sqlite');
|
|
1012
|
-
const { results } = await
|
|
1295
|
+
const { results } = await db
|
|
1013
1296
|
.prepare(statement.sql)
|
|
1014
1297
|
.bind(...statement.params)
|
|
1015
1298
|
.all();
|
|
@@ -1037,6 +1320,24 @@ export class D1ServerStorage {
|
|
|
1037
1320
|
.bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
|
|
1038
1321
|
.run();
|
|
1039
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
|
+
}
|
|
1040
1341
|
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
1041
1342
|
const row = await this.#db
|
|
1042
1343
|
.prepare('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
|
|
@@ -1056,7 +1357,8 @@ export class D1ServerStorage {
|
|
|
1056
1357
|
}));
|
|
1057
1358
|
}
|
|
1058
1359
|
async listRowsReferencingBlob(partition, blobId) {
|
|
1059
|
-
const
|
|
1360
|
+
const db = this.#schemaDatabase();
|
|
1361
|
+
const { results: refs } = await db
|
|
1060
1362
|
.prepare('SELECT tbl, row_id FROM sync_blob_refs WHERE partition=? AND blob_id=?')
|
|
1061
1363
|
.bind(partition, blobId)
|
|
1062
1364
|
.all();
|
|
@@ -1065,7 +1367,7 @@ export class D1ServerStorage {
|
|
|
1065
1367
|
const compiled = this.#tables?.get(ref.tbl);
|
|
1066
1368
|
if (compiled === undefined)
|
|
1067
1369
|
continue; // table no longer in the schema
|
|
1068
|
-
const row = await
|
|
1370
|
+
const row = await db
|
|
1069
1371
|
.prepare(selectRowScopesSql(compiled, 'sqlite'))
|
|
1070
1372
|
.bind(partition, ref.row_id)
|
|
1071
1373
|
.first();
|
|
@@ -1167,7 +1469,8 @@ export class D1ServerStorage {
|
|
|
1167
1469
|
return out;
|
|
1168
1470
|
}
|
|
1169
1471
|
async getRowScopes(partition, table, rowId) {
|
|
1170
|
-
const
|
|
1472
|
+
const db = this.#schemaDatabase();
|
|
1473
|
+
const record = await db
|
|
1171
1474
|
.prepare(selectRowScopesSql(this.table(table), 'sqlite'))
|
|
1172
1475
|
.bind(partition, rowId)
|
|
1173
1476
|
.first();
|
|
@@ -67,6 +67,8 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
67
67
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
68
68
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
69
69
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
70
|
+
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
71
|
+
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
70
72
|
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
|
71
73
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
72
74
|
listRowsReferencingBlob(partition: string, blobId: string): Promise<{
|