@syncular/server 0.22.0 → 0.24.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 +14 -2
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +7 -1
- package/dist/blob-handlers.js +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +11 -3
- package/dist/d1-storage.js +186 -36
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.d.ts +7 -1
- package/dist/operations.js +5 -3
- package/dist/postgres-storage.d.ts +12 -4
- package/dist/postgres-storage.js +310 -59
- package/dist/push.js +31 -7
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.js +20 -1
- package/dist/relational-rows.d.ts +31 -12
- package/dist/relational-rows.js +121 -26
- package/dist/segment-download.js +8 -0
- package/dist/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +15 -0
- package/dist/sqlite-storage.d.ts +18 -3
- package/dist/sqlite-storage.js +327 -82
- package/dist/storage-errors.d.ts +5 -3
- package/dist/storage-errors.js +13 -2
- package/dist/storage-query.d.ts +8 -1
- package/dist/storage-query.js +12 -0
- package/dist/storage.d.ts +153 -14
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +33 -15
- package/package.json +2 -2
- package/src/admin.ts +13 -1
- package/src/blob-handlers.ts +1 -1
- package/src/context.ts +8 -0
- package/src/d1-storage.ts +282 -37
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +32 -14
- package/src/postgres-storage.ts +515 -68
- package/src/push.ts +31 -1
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +22 -1
- package/src/relational-rows.ts +145 -30
- package/src/segment-download.ts +16 -0
- package/src/sqlite-dialect.ts +15 -0
- package/src/sqlite-storage.ts +491 -96
- package/src/storage-errors.ts +33 -3
- package/src/storage-query.ts +23 -1
- package/src/storage.ts +245 -12
- package/src/validate.ts +40 -15
package/src/d1-storage.ts
CHANGED
|
@@ -45,11 +45,14 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
45
45
|
import { decodeRow, type RowValue } from '@syncular/core';
|
|
46
46
|
import {
|
|
47
47
|
bindAuthoritativePartition,
|
|
48
|
+
type BoundAuthoritativeQuery,
|
|
48
49
|
prepareAuthoritativeQuery,
|
|
49
50
|
} from './authoritative-query';
|
|
50
51
|
import { syncError } from './errors';
|
|
51
52
|
import {
|
|
52
53
|
assertAppendOnlyMigration,
|
|
54
|
+
assertPhysicalColumns,
|
|
55
|
+
assertStoredLayouts,
|
|
53
56
|
commitWindowPageSql,
|
|
54
57
|
deleteRowSql,
|
|
55
58
|
deleteSqliteRowScopesSql,
|
|
@@ -81,6 +84,7 @@ import {
|
|
|
81
84
|
asUint8Array,
|
|
82
85
|
collectCommitWindowPage,
|
|
83
86
|
deserializePushResult,
|
|
87
|
+
placeholders,
|
|
84
88
|
type SqliteCommitWindowRecord,
|
|
85
89
|
type SqliteRowRecord,
|
|
86
90
|
serializePushResult,
|
|
@@ -90,6 +94,7 @@ import {
|
|
|
90
94
|
import type {
|
|
91
95
|
AuthoritativeQueryRequest,
|
|
92
96
|
AuthoritativeQueryResult,
|
|
97
|
+
CheckpointDeclaration,
|
|
93
98
|
ClientCursorInfo,
|
|
94
99
|
ClientRecord,
|
|
95
100
|
ClientSubscription,
|
|
@@ -111,8 +116,10 @@ import type {
|
|
|
111
116
|
ScopeActivityQuery,
|
|
112
117
|
ScopeCommitActivity,
|
|
113
118
|
ServerStorage,
|
|
119
|
+
ServeGate,
|
|
114
120
|
StorageTransaction,
|
|
115
121
|
StoredCommit,
|
|
122
|
+
StoredCheckpoint,
|
|
116
123
|
StoredPushResult,
|
|
117
124
|
StoredReaction,
|
|
118
125
|
StoredRow,
|
|
@@ -199,6 +206,49 @@ function relationalValuesEqual(left: RowValue, right: RowValue): boolean {
|
|
|
199
206
|
return Object.is(left, right);
|
|
200
207
|
}
|
|
201
208
|
|
|
209
|
+
/** Run one partition-bound registered query and the cursor read as one batch. */
|
|
210
|
+
async function queryAuthoritativeOn(
|
|
211
|
+
db: Pick<D1Database, 'prepare' | 'batch'>,
|
|
212
|
+
partition: string,
|
|
213
|
+
prepared: BoundAuthoritativeQuery,
|
|
214
|
+
): Promise<AuthoritativeQueryResult> {
|
|
215
|
+
const results = await db.batch([
|
|
216
|
+
db.prepare(prepared.sql).bind(...prepared.params),
|
|
217
|
+
db
|
|
218
|
+
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
219
|
+
.bind(partition),
|
|
220
|
+
]);
|
|
221
|
+
const rowsResult = results[0];
|
|
222
|
+
const cursorResult = results[1];
|
|
223
|
+
if (
|
|
224
|
+
typeof rowsResult !== 'object' ||
|
|
225
|
+
rowsResult === null ||
|
|
226
|
+
!('results' in rowsResult) ||
|
|
227
|
+
!Array.isArray(rowsResult.results) ||
|
|
228
|
+
typeof cursorResult !== 'object' ||
|
|
229
|
+
cursorResult === null ||
|
|
230
|
+
!('results' in cursorResult) ||
|
|
231
|
+
!Array.isArray(cursorResult.results)
|
|
232
|
+
) {
|
|
233
|
+
throw new Error('D1 registered query returned an invalid batch result');
|
|
234
|
+
}
|
|
235
|
+
const cursor = cursorResult.results[0];
|
|
236
|
+
const maxCommitSeq =
|
|
237
|
+
typeof cursor === 'object' &&
|
|
238
|
+
cursor !== null &&
|
|
239
|
+
'max_commit_seq' in cursor &&
|
|
240
|
+
typeof cursor.max_commit_seq === 'number'
|
|
241
|
+
? cursor.max_commit_seq
|
|
242
|
+
: 0;
|
|
243
|
+
return {
|
|
244
|
+
rows: rowsResult.results.filter(
|
|
245
|
+
(row): row is Readonly<Record<string, unknown>> =>
|
|
246
|
+
typeof row === 'object' && row !== null,
|
|
247
|
+
),
|
|
248
|
+
maxCommitSeq,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
202
252
|
/** Read-your-own-writes overlay entry: a buffered upsert, or a deletion. */
|
|
203
253
|
type PendingRow =
|
|
204
254
|
| { readonly kind: 'row'; readonly row: StoredRow }
|
|
@@ -210,6 +260,10 @@ class D1Transaction implements StorageTransaction {
|
|
|
210
260
|
readonly #db: Pick<D1Database, 'prepare' | 'batch'>;
|
|
211
261
|
readonly #partition: string;
|
|
212
262
|
readonly #resolveTable: (name: string) => CompiledTable;
|
|
263
|
+
/** Storage-owned relation rewrite, bound to this transaction's partition. */
|
|
264
|
+
readonly #prepareQuery: (
|
|
265
|
+
query: AuthoritativeQueryRequest,
|
|
266
|
+
) => BoundAuthoritativeQuery;
|
|
213
267
|
readonly #pushApplySerialized: boolean;
|
|
214
268
|
readonly #buffer: BufferedStatement[] = [];
|
|
215
269
|
#open = true;
|
|
@@ -238,11 +292,13 @@ class D1Transaction implements StorageTransaction {
|
|
|
238
292
|
db: Pick<D1Database, 'prepare' | 'batch'>,
|
|
239
293
|
partition: string,
|
|
240
294
|
resolveTable: (name: string) => CompiledTable,
|
|
295
|
+
prepareQuery: (query: AuthoritativeQueryRequest) => BoundAuthoritativeQuery,
|
|
241
296
|
pushApplySerialized: boolean,
|
|
242
297
|
) {
|
|
243
298
|
this.#db = db;
|
|
244
299
|
this.#partition = partition;
|
|
245
300
|
this.#resolveTable = resolveTable;
|
|
301
|
+
this.#prepareQuery = prepareQuery;
|
|
246
302
|
this.#pushApplySerialized = pushApplySerialized;
|
|
247
303
|
}
|
|
248
304
|
|
|
@@ -435,6 +491,25 @@ class D1Transaction implements StorageTransaction {
|
|
|
435
491
|
.slice(0, query.limit);
|
|
436
492
|
}
|
|
437
493
|
|
|
494
|
+
async queryAuthoritative(
|
|
495
|
+
query: AuthoritativeQueryRequest,
|
|
496
|
+
): Promise<AuthoritativeQueryResult> {
|
|
497
|
+
this.#assertOpen();
|
|
498
|
+
// D1 buffers writes until commit() and cannot overlay them on generated
|
|
499
|
+
// SQL, so a read over a written table would return committed state
|
|
500
|
+
// without this transaction's staged rows. Refuse it instead.
|
|
501
|
+
for (const key of this.#pending.keys()) {
|
|
502
|
+
if (query.tables.includes(key.slice(0, key.indexOf('\u0000')))) {
|
|
503
|
+
throw new StorageQueryError('sync.storage.query_over_staged_writes');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return queryAuthoritativeOn(
|
|
507
|
+
this.#db,
|
|
508
|
+
this.#partition,
|
|
509
|
+
this.#prepareQuery(query),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
438
513
|
async lockPartitionForPush(): Promise<void> {
|
|
439
514
|
this.#assertOpen();
|
|
440
515
|
if (!this.#pushApplySerialized) {
|
|
@@ -447,6 +522,31 @@ class D1Transaction implements StorageTransaction {
|
|
|
447
522
|
this.#pushApplyCheckpoint = this.#buffer.length;
|
|
448
523
|
}
|
|
449
524
|
|
|
525
|
+
advanceCheckpoint(
|
|
526
|
+
_name: string,
|
|
527
|
+
_ownerEpoch: number,
|
|
528
|
+
_watermark: number,
|
|
529
|
+
_observedRows: number,
|
|
530
|
+
_nowMs: number,
|
|
531
|
+
): Promise<boolean> {
|
|
532
|
+
// D1 installs no fence and declares no checkpoints in this release.
|
|
533
|
+
return Promise.reject(
|
|
534
|
+
new StorageQueryError('sync.storage.checkpoint_unsupported'),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async readServeGate(_runningSchemaVersion: number): Promise<ServeGate> {
|
|
539
|
+
this.#assertOpen();
|
|
540
|
+
const marker = await this.#db
|
|
541
|
+
.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
|
|
542
|
+
.first<{ schema_version: number }>();
|
|
543
|
+
return {
|
|
544
|
+
storedSchemaVersion: marker?.schema_version ?? 0,
|
|
545
|
+
logEpoch: undefined,
|
|
546
|
+
pending: [],
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
450
550
|
async commitRejectedPushResult(
|
|
451
551
|
clientId: string,
|
|
452
552
|
clientCommitId: string,
|
|
@@ -777,6 +877,27 @@ export interface D1ServerStorageOptions {
|
|
|
777
877
|
readonly commitValidationSerialized?: boolean;
|
|
778
878
|
}
|
|
779
879
|
|
|
880
|
+
/**
|
|
881
|
+
* The core tables `D1ServerStorage` reads or writes outside schema migration.
|
|
882
|
+
* `migrateSchema` creates them in its core phase, which runs only when the
|
|
883
|
+
* version marker advances, so a same-version open refuses a database missing
|
|
884
|
+
* one. `sync_backfill_checkpoints` and `sync_writer_fence` stay out: D1
|
|
885
|
+
* declares no checkpoints and installs no fence.
|
|
886
|
+
*/
|
|
887
|
+
const D1_CORE_TABLES = [
|
|
888
|
+
'sync_partitions',
|
|
889
|
+
'sync_partition_registry',
|
|
890
|
+
'sync_row_scopes',
|
|
891
|
+
'sync_commits',
|
|
892
|
+
'sync_changes',
|
|
893
|
+
'sync_change_scopes',
|
|
894
|
+
'sync_push_results',
|
|
895
|
+
'sync_reactions',
|
|
896
|
+
'sync_clients',
|
|
897
|
+
'sync_blob_refs',
|
|
898
|
+
'sync_tombstones',
|
|
899
|
+
] as const;
|
|
900
|
+
|
|
780
901
|
interface D1MigrationState {
|
|
781
902
|
phase: 'core' | 'prepare' | 'ddl' | 'rewrite';
|
|
782
903
|
offset: number;
|
|
@@ -834,7 +955,15 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
834
955
|
return table;
|
|
835
956
|
}
|
|
836
957
|
|
|
837
|
-
async ensureSchema(
|
|
958
|
+
async ensureSchema(
|
|
959
|
+
schema: CompiledSchema,
|
|
960
|
+
checkpoints?: readonly CheckpointDeclaration[],
|
|
961
|
+
): Promise<void> {
|
|
962
|
+
if (checkpoints !== undefined && checkpoints.length > 0) {
|
|
963
|
+
// No database-side fence can be installed atomically with schema
|
|
964
|
+
// visibility here; refuse instead of accepting a weaker guarantee.
|
|
965
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
966
|
+
}
|
|
838
967
|
if (this.#schemaVersion === schema.version) {
|
|
839
968
|
await this.#schemaDatabase().batch([]);
|
|
840
969
|
return;
|
|
@@ -910,6 +1039,39 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
910
1039
|
this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'),
|
|
911
1040
|
);
|
|
912
1041
|
if (claim === null && marker?.schema_version === schema.version) {
|
|
1042
|
+
assertStoredLayouts(schema, marker.layouts);
|
|
1043
|
+
statementsExecuted++;
|
|
1044
|
+
const present = await this.#db
|
|
1045
|
+
.prepare(
|
|
1046
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name IN (${placeholders(D1_CORE_TABLES.length)})`,
|
|
1047
|
+
)
|
|
1048
|
+
.bind(...D1_CORE_TABLES)
|
|
1049
|
+
.all<{ name: string }>();
|
|
1050
|
+
const presentNames = new Set(present.results.map((row) => row.name));
|
|
1051
|
+
for (const name of D1_CORE_TABLES) {
|
|
1052
|
+
if (!presentNames.has(name)) {
|
|
1053
|
+
throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
|
|
1054
|
+
table: name,
|
|
1055
|
+
reason: 'missing_table',
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
for (const table of tables) {
|
|
1060
|
+
statementsExecuted++;
|
|
1061
|
+
const { results } = await this.#db
|
|
1062
|
+
.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
|
|
1063
|
+
.all<{ name: string; type: string; notnull: number; pk: number }>();
|
|
1064
|
+
assertPhysicalColumns(
|
|
1065
|
+
table,
|
|
1066
|
+
results.map((column) => ({
|
|
1067
|
+
name: column.name,
|
|
1068
|
+
type: column.type,
|
|
1069
|
+
notNull: column.notnull === 1,
|
|
1070
|
+
primaryKeyPosition: column.pk,
|
|
1071
|
+
})),
|
|
1072
|
+
'sqlite',
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
913
1075
|
this.#tables = schema.tables;
|
|
914
1076
|
this.#schemaVersion = schema.version;
|
|
915
1077
|
return { complete: true, statementsExecuted };
|
|
@@ -1412,6 +1574,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1412
1574
|
throw new StorageQueryError('sync.storage.schema_changed');
|
|
1413
1575
|
return table;
|
|
1414
1576
|
},
|
|
1577
|
+
(query) => this.#prepareAuthoritativeQuery(partition, query),
|
|
1415
1578
|
this.#pushApplySerialized,
|
|
1416
1579
|
);
|
|
1417
1580
|
}
|
|
@@ -1427,14 +1590,29 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1427
1590
|
async queryAuthoritative(
|
|
1428
1591
|
partition: string,
|
|
1429
1592
|
query: AuthoritativeQueryRequest,
|
|
1593
|
+
_checkpoints?: readonly CheckpointDeclaration[],
|
|
1430
1594
|
): Promise<AuthoritativeQueryResult> {
|
|
1595
|
+
// D1 declares no checkpoints. `#schemaDatabase` prepends a guard that
|
|
1596
|
+
// re-checks the published marker inside this same batch, so condition 2
|
|
1597
|
+
// is already evaluated with the query (§2.4 D1 schema readiness).
|
|
1431
1598
|
const db = this.#schemaDatabase();
|
|
1599
|
+
return queryAuthoritativeOn(
|
|
1600
|
+
db,
|
|
1601
|
+
partition,
|
|
1602
|
+
this.#prepareAuthoritativeQuery(partition, query),
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
#prepareAuthoritativeQuery(
|
|
1607
|
+
partition: string,
|
|
1608
|
+
query: AuthoritativeQueryRequest,
|
|
1609
|
+
): BoundAuthoritativeQuery {
|
|
1432
1610
|
if (this.#tables === undefined) {
|
|
1433
1611
|
throw new Error(
|
|
1434
1612
|
'ensureSchema(schema) must run before registered queries',
|
|
1435
1613
|
);
|
|
1436
1614
|
}
|
|
1437
|
-
|
|
1615
|
+
return bindAuthoritativePartition(
|
|
1438
1616
|
prepareAuthoritativeQuery(
|
|
1439
1617
|
query.plan,
|
|
1440
1618
|
query.params,
|
|
@@ -1443,41 +1621,6 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1443
1621
|
),
|
|
1444
1622
|
partition,
|
|
1445
1623
|
);
|
|
1446
|
-
const results = await db.batch([
|
|
1447
|
-
db.prepare(prepared.sql).bind(...prepared.params),
|
|
1448
|
-
db
|
|
1449
|
-
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
1450
|
-
.bind(partition),
|
|
1451
|
-
]);
|
|
1452
|
-
const rowsResult = results[0];
|
|
1453
|
-
const cursorResult = results[1];
|
|
1454
|
-
if (
|
|
1455
|
-
typeof rowsResult !== 'object' ||
|
|
1456
|
-
rowsResult === null ||
|
|
1457
|
-
!('results' in rowsResult) ||
|
|
1458
|
-
!Array.isArray(rowsResult.results) ||
|
|
1459
|
-
typeof cursorResult !== 'object' ||
|
|
1460
|
-
cursorResult === null ||
|
|
1461
|
-
!('results' in cursorResult) ||
|
|
1462
|
-
!Array.isArray(cursorResult.results)
|
|
1463
|
-
) {
|
|
1464
|
-
throw new Error('D1 registered query returned an invalid batch result');
|
|
1465
|
-
}
|
|
1466
|
-
const cursor = cursorResult.results[0];
|
|
1467
|
-
const maxCommitSeq =
|
|
1468
|
-
typeof cursor === 'object' &&
|
|
1469
|
-
cursor !== null &&
|
|
1470
|
-
'max_commit_seq' in cursor &&
|
|
1471
|
-
typeof cursor.max_commit_seq === 'number'
|
|
1472
|
-
? cursor.max_commit_seq
|
|
1473
|
-
: 0;
|
|
1474
|
-
return {
|
|
1475
|
-
rows: rowsResult.results.filter(
|
|
1476
|
-
(row): row is Readonly<Record<string, unknown>> =>
|
|
1477
|
-
typeof row === 'object' && row !== null,
|
|
1478
|
-
),
|
|
1479
|
-
maxCommitSeq,
|
|
1480
|
-
};
|
|
1481
1624
|
}
|
|
1482
1625
|
|
|
1483
1626
|
async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
|
|
@@ -1591,6 +1734,108 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1591
1734
|
return row?.seq ?? 0;
|
|
1592
1735
|
}
|
|
1593
1736
|
|
|
1737
|
+
async readCheckpoints(_partition: string): Promise<StoredCheckpoint[]> {
|
|
1738
|
+
// D1 supports no declared backfill checkpoints in this release: migrations
|
|
1739
|
+
// run out of band and no database-side fence can be installed atomically
|
|
1740
|
+
// with schema visibility. No checkpoint row can exist.
|
|
1741
|
+
return [];
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
async readServeGate(
|
|
1745
|
+
partition: string,
|
|
1746
|
+
_runningSchemaVersion: number,
|
|
1747
|
+
): Promise<ServeGate> {
|
|
1748
|
+
// D1 has no checkpoints. The published marker is the only gate input;
|
|
1749
|
+
// `#schemaDatabase` already re-checks it per protected batch.
|
|
1750
|
+
const marker = await this.#db
|
|
1751
|
+
.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
|
|
1752
|
+
.first<{ schema_version: number }>();
|
|
1753
|
+
return {
|
|
1754
|
+
storedSchemaVersion: marker?.schema_version ?? 0,
|
|
1755
|
+
logEpoch: await this.getPartitionLogEpoch(partition),
|
|
1756
|
+
pending: [],
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
async declareCheckpoint(
|
|
1761
|
+
_partition: string,
|
|
1762
|
+
_name: string,
|
|
1763
|
+
_schemaVersion: number,
|
|
1764
|
+
_nowMs: number,
|
|
1765
|
+
): Promise<StoredCheckpoint> {
|
|
1766
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
async activateCheckpoint(
|
|
1770
|
+
_partition: string,
|
|
1771
|
+
_name: string,
|
|
1772
|
+
_ownerEpoch: number,
|
|
1773
|
+
_watermark: number,
|
|
1774
|
+
_sources: readonly string[],
|
|
1775
|
+
_nowMs: number,
|
|
1776
|
+
): Promise<'activated' | 'stale' | 'unverifiable'> {
|
|
1777
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
async claimCheckpoint(
|
|
1781
|
+
_partition: string,
|
|
1782
|
+
_name: string,
|
|
1783
|
+
_schemaVersion: number,
|
|
1784
|
+
_nowMs: number,
|
|
1785
|
+
): Promise<StoredCheckpoint> {
|
|
1786
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
async advanceCheckpoint(
|
|
1790
|
+
_partition: string,
|
|
1791
|
+
_name: string,
|
|
1792
|
+
_ownerEpoch: number,
|
|
1793
|
+
_watermark: number,
|
|
1794
|
+
_observedRows: number,
|
|
1795
|
+
_nowMs: number,
|
|
1796
|
+
): Promise<boolean> {
|
|
1797
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
async sourceCoverageSeq(
|
|
1801
|
+
partition: string,
|
|
1802
|
+
tables: readonly string[],
|
|
1803
|
+
): Promise<number> {
|
|
1804
|
+
if (tables.length === 0) return 0;
|
|
1805
|
+
const tableParams = tables.map(() => '?').join(',');
|
|
1806
|
+
const row = await this.#db
|
|
1807
|
+
.prepare(
|
|
1808
|
+
`SELECT max(commit_seq) AS seq FROM sync_changes
|
|
1809
|
+
WHERE partition=? AND tbl IN (${tableParams})`,
|
|
1810
|
+
)
|
|
1811
|
+
.bind(partition, ...tables)
|
|
1812
|
+
.first<{ seq: number | null }>();
|
|
1813
|
+
return row?.seq ?? 0;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
async hasSourceChangesAbove(
|
|
1817
|
+
partition: string,
|
|
1818
|
+
tables: readonly string[],
|
|
1819
|
+
seq: number,
|
|
1820
|
+
): Promise<'clean' | 'changed' | 'unverifiable'> {
|
|
1821
|
+
// Read the horizon before the window scan: a horizon past `seq` means the
|
|
1822
|
+
// history that would answer the question is gone, so an empty scan proves
|
|
1823
|
+
// nothing and must not be reported as `clean`.
|
|
1824
|
+
const horizon = await this.getHorizonSeq(partition);
|
|
1825
|
+
if (tables.length === 0) return 'clean';
|
|
1826
|
+
const tableParams = tables.map(() => '?').join(',');
|
|
1827
|
+
const hit = await this.#db
|
|
1828
|
+
.prepare(
|
|
1829
|
+
`SELECT 1 AS hit FROM sync_changes
|
|
1830
|
+
WHERE partition=? AND tbl IN (${tableParams}) AND commit_seq>?
|
|
1831
|
+
LIMIT 1`,
|
|
1832
|
+
)
|
|
1833
|
+
.bind(partition, ...tables, seq)
|
|
1834
|
+
.first<{ hit: number }>();
|
|
1835
|
+
if (hit !== null) return 'changed';
|
|
1836
|
+
return horizon > seq ? 'unverifiable' : 'clean';
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1594
1839
|
async getRow(
|
|
1595
1840
|
partition: string,
|
|
1596
1841
|
table: string,
|
package/src/errors.ts
CHANGED
|
@@ -167,6 +167,16 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
167
167
|
recommendedAction: 'retryLater',
|
|
168
168
|
httpStatus: 500,
|
|
169
169
|
},
|
|
170
|
+
// §2.4 on a non-D1 backend: the server refuses while a declared backfill
|
|
171
|
+
// checkpoint is incomplete or the database is newer than the running build.
|
|
172
|
+
// Formerly host-only; promoted to the §10.2 catalogue so an in-flight
|
|
173
|
+
// request can be refused with a distinguishable identity.
|
|
174
|
+
'sync.schema_not_ready': {
|
|
175
|
+
category: 'internal',
|
|
176
|
+
retryable: true,
|
|
177
|
+
recommendedAction: 'retryLater',
|
|
178
|
+
httpStatus: 503,
|
|
179
|
+
},
|
|
170
180
|
'sync.too_many_operations': {
|
|
171
181
|
category: 'invalid-request',
|
|
172
182
|
retryable: false,
|
package/src/handler.ts
CHANGED
|
@@ -51,10 +51,17 @@ import {
|
|
|
51
51
|
subscriptionSection,
|
|
52
52
|
} from './pull';
|
|
53
53
|
import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
|
|
54
|
+
import { serveNotReadyError } from './readiness';
|
|
54
55
|
import type { CompiledSchema } from './schema';
|
|
55
56
|
import { compileSchema } from './schema';
|
|
56
57
|
import { computeEffective, type ResolvedScopes } from './scopes';
|
|
57
|
-
import
|
|
58
|
+
import { StorageQueryError } from './storage-errors';
|
|
59
|
+
import {
|
|
60
|
+
type ClientSubscription,
|
|
61
|
+
type PartitionRegistryEntry,
|
|
62
|
+
serveGateRefusal,
|
|
63
|
+
serveGateTokenChanged,
|
|
64
|
+
} from './storage';
|
|
58
65
|
|
|
59
66
|
interface RequestPlan {
|
|
60
67
|
readonly wireVersion: number;
|
|
@@ -709,13 +716,64 @@ async function createStreamCore(
|
|
|
709
716
|
const schema = compileSchema(ctx.schema);
|
|
710
717
|
// Relational row tables: create/
|
|
711
718
|
// migrate on first contact; memoized per storage instance thereafter.
|
|
712
|
-
|
|
719
|
+
try {
|
|
720
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
if (
|
|
723
|
+
error instanceof StorageQueryError &&
|
|
724
|
+
error.code === 'sync.storage.checkpoint_incomplete'
|
|
725
|
+
) {
|
|
726
|
+
const refusal = serveGateRefusal(
|
|
727
|
+
await ctx.storage.readServeGate(ctx.partition, schema.version),
|
|
728
|
+
schema.version,
|
|
729
|
+
ctx.checkpoints,
|
|
730
|
+
);
|
|
731
|
+
throw refusal !== undefined ? serveNotReadyError(refusal) : error;
|
|
732
|
+
}
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
// RFC 0007 serve gate: re-read stored state on every request so an
|
|
736
|
+
// already-serving process notices a migration or a newly declared
|
|
737
|
+
// checkpoint. Read after `touchAuthenticatedPartition` establishes the
|
|
738
|
+
// partition's log epoch, so the entry token is the epoch this request
|
|
739
|
+
// serves under. Nothing has escaped at this point; the push transaction
|
|
740
|
+
// re-evaluates the gate under the partition lock.
|
|
713
741
|
const registry = await touchAuthenticatedPartition(ctx);
|
|
742
|
+
const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
|
|
743
|
+
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
744
|
+
if (refusal !== undefined) throw serveNotReadyError(refusal);
|
|
714
745
|
const plan = await planRequest(request, ctx, schema, registry);
|
|
715
|
-
if (events === undefined) return streamResponse(plan, ctx, schema);
|
|
716
746
|
const report: RequestReport = { outcome: 'ok' };
|
|
747
|
+
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
748
|
+
// builds the whole response (its data reads) before yielding the first
|
|
749
|
+
// frame, then compares the gate token across those reads. Nothing escapes
|
|
750
|
+
// before the verification, and the buffered size is bounded by the request's
|
|
751
|
+
// pull limits and the server's inline segment cap. Every request verifies,
|
|
752
|
+
// including a mixed push+pull: a write that already applied when the token
|
|
753
|
+
// changed is durable and replayable under the same commit id / idempotency
|
|
754
|
+
// key, so refusing the request is safe (proved in the serve-gate lane).
|
|
755
|
+
const verified = async function* (): AsyncGenerator<Uint8Array> {
|
|
756
|
+
const buffered: Uint8Array[] = [];
|
|
757
|
+
for await (const chunk of streamResponse(plan, ctx, schema, report)) {
|
|
758
|
+
buffered.push(chunk);
|
|
759
|
+
}
|
|
760
|
+
const after = await ctx.storage.readServeGate(
|
|
761
|
+
ctx.partition,
|
|
762
|
+
schema.version,
|
|
763
|
+
);
|
|
764
|
+
const changed = serveGateTokenChanged(gate, after);
|
|
765
|
+
const afterRefusal = serveGateRefusal(
|
|
766
|
+
after,
|
|
767
|
+
schema.version,
|
|
768
|
+
ctx.checkpoints,
|
|
769
|
+
);
|
|
770
|
+
if (afterRefusal !== undefined) throw serveNotReadyError(afterRefusal);
|
|
771
|
+
if (changed !== undefined) throw serveNotReadyError(changed);
|
|
772
|
+
for (const chunk of buffered) yield chunk;
|
|
773
|
+
};
|
|
774
|
+
if (events === undefined) return verified();
|
|
717
775
|
return instrumentedStream(
|
|
718
|
-
|
|
776
|
+
verified(),
|
|
719
777
|
ctx,
|
|
720
778
|
events,
|
|
721
779
|
plan,
|
package/src/operations.ts
CHANGED
|
@@ -22,8 +22,13 @@ import { SyncError, syncError } from './errors';
|
|
|
22
22
|
import { processPushOperationsWithTrace } from './push';
|
|
23
23
|
import { compileSchema } from './schema';
|
|
24
24
|
import { authorizeWrite, type ResolvedScopes } from './scopes';
|
|
25
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
AuthoritativeQueryRequest,
|
|
27
|
+
AuthoritativeQueryResult,
|
|
28
|
+
AuthoritativeQueryValue,
|
|
29
|
+
} from './storage';
|
|
26
30
|
import type { StorageTransaction } from './storage';
|
|
31
|
+
import { transactionQuery } from './storage-query';
|
|
27
32
|
import { toValidateRow, type ValidateRow } from './validate';
|
|
28
33
|
|
|
29
34
|
export interface RemoteQueryDependency {
|
|
@@ -107,6 +112,14 @@ export interface RemoteCommandContext {
|
|
|
107
112
|
readonly operationId: string;
|
|
108
113
|
readonly requestId: string;
|
|
109
114
|
getRow(table: string, rowId: string): Promise<ValidateRow | undefined>;
|
|
115
|
+
/**
|
|
116
|
+
* Run a generated registered query on the command's push transaction
|
|
117
|
+
* (§6.7). The storage binds every relation to the partition; unlike
|
|
118
|
+
* `getRow`, the rows are not filtered by the actor's scopes.
|
|
119
|
+
*/
|
|
120
|
+
queryAuthoritative(
|
|
121
|
+
query: AuthoritativeQueryRequest,
|
|
122
|
+
): Promise<AuthoritativeQueryResult>;
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
export interface RemoteCommandOptions<Input> {
|
|
@@ -415,22 +428,26 @@ export function registerRemoteQuery<Params>(
|
|
|
415
428
|
'selected SQL has no generated relation plan; regenerate queries',
|
|
416
429
|
);
|
|
417
430
|
}
|
|
418
|
-
await ctx.storage.ensureSchema(schema);
|
|
431
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
419
432
|
const prefix = 'SELECT * FROM (';
|
|
420
433
|
let result;
|
|
421
434
|
try {
|
|
422
|
-
result = await ctx.storage.queryAuthoritative(
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
435
|
+
result = await ctx.storage.queryAuthoritative(
|
|
436
|
+
ctx.partition,
|
|
437
|
+
{
|
|
438
|
+
plan: {
|
|
439
|
+
sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
|
|
440
|
+
relations: plan.relations.map((relation) => ({
|
|
441
|
+
...relation,
|
|
442
|
+
start: relation.start + prefix.length,
|
|
443
|
+
end: relation.end + prefix.length,
|
|
444
|
+
})),
|
|
445
|
+
},
|
|
446
|
+
params: [...descriptor.bind(params), options.maxRows + 1],
|
|
447
|
+
tables: descriptor.tables,
|
|
430
448
|
},
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
});
|
|
449
|
+
ctx.checkpoints,
|
|
450
|
+
);
|
|
434
451
|
} catch (error) {
|
|
435
452
|
if (error instanceof SyncError) throw error;
|
|
436
453
|
throw syncError(
|
|
@@ -566,6 +583,7 @@ function commandContext(
|
|
|
566
583
|
decodeRow(table.columns, stored.payload),
|
|
567
584
|
);
|
|
568
585
|
},
|
|
586
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
569
587
|
};
|
|
570
588
|
}
|
|
571
589
|
|
|
@@ -603,7 +621,7 @@ export function registerRemoteCommand<Input>(
|
|
|
603
621
|
}
|
|
604
622
|
const resolved: ResolvedScopes = { ok: true, allowed };
|
|
605
623
|
const schema = compileSchema(ctx.schema);
|
|
606
|
-
await ctx.storage.ensureSchema(schema);
|
|
624
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
607
625
|
const processed = await processPushOperationsWithTrace(
|
|
608
626
|
ctx,
|
|
609
627
|
schema,
|