@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/README.md
CHANGED
|
@@ -168,6 +168,18 @@ the stable code/details to localized copy instead of displaying that message.
|
|
|
168
168
|
|
|
169
169
|
Validators are per-operation. A validator must not mutate the row it receives.
|
|
170
170
|
|
|
171
|
+
A validator that decides from other rows (a membership, a role, a grant) calls
|
|
172
|
+
`context.queryAuthoritative` with the request `storage.queryAuthoritative`
|
|
173
|
+
takes, built from a generated query descriptor. The storage binds every
|
|
174
|
+
relation to the commit's partition and runs the statement on the push
|
|
175
|
+
transaction's connection, so the read sees the operations staged before this
|
|
176
|
+
one and needs no second pool connection. Never call
|
|
177
|
+
`storage.queryAuthoritative` from a validator: on SQLite and PGlite it waits
|
|
178
|
+
for the push transaction and the push never completes, and on a pool it reads
|
|
179
|
+
committed state. The whole-commit reader, the reaction planner reader, and the
|
|
180
|
+
remote command context expose the same method. See
|
|
181
|
+
[Read a generated query inside a push transaction](https://syncular.dev/guide-remote-operations/#read-a-generated-query-inside-a-push-transaction).
|
|
182
|
+
|
|
171
183
|
For a multi-row or multi-table invariant, install `commitValidator`. It runs
|
|
172
184
|
once after every operation is staged in the same transaction, and can inspect
|
|
173
185
|
both the decoded sibling operations and final candidate state:
|
|
@@ -215,8 +227,8 @@ const config: SyncServerConfig = {
|
|
|
215
227
|
};
|
|
216
228
|
```
|
|
217
229
|
|
|
218
|
-
The callback also receives `read.getRow()
|
|
219
|
-
Those reads see the final candidate state, including staged sibling upserts and
|
|
230
|
+
The callback also receives `read.getRow()`, bounded `read.scanRows()`, and
|
|
231
|
+
`read.queryAuthoritative()` APIs. Those reads see the final candidate state, including staged sibling upserts and
|
|
220
232
|
deletes. Syncular serializes the partition before any operation read so two
|
|
221
233
|
aggregate validators cannot both accept mutually invalid candidates. It also
|
|
222
234
|
re-checks idempotency after taking that lock and persists a rejected outcome
|
package/dist/admin.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { LeaseRecord, LeaseStore } from './lease-store.js';
|
|
|
21
21
|
import { type RetentionPolicy } from './prune.js';
|
|
22
22
|
import { type ServerSchema } from './schema.js';
|
|
23
23
|
import type { SegmentStore, SegmentStoreStats } from './segment-store.js';
|
|
24
|
-
import type { CommitMetadata, ReactionStatus, ScopeCommitActivity, ServerStorage, StoredReaction } from './storage.js';
|
|
24
|
+
import type { CheckpointDeclaration, CommitMetadata, ReactionStatus, ScopeCommitActivity, ServerStorage, StoredReaction } from './storage.js';
|
|
25
25
|
/** A connected/known client as the console sees it (§4.5, §8.1). */
|
|
26
26
|
export interface AdminClient {
|
|
27
27
|
readonly clientId: string;
|
|
@@ -139,6 +139,8 @@ export interface SyncularAdminOptions {
|
|
|
139
139
|
* a storage instance that has not served a sync request yet.
|
|
140
140
|
*/
|
|
141
141
|
readonly schema?: ServerSchema;
|
|
142
|
+
/** RFC 0007: declarations `inspectRow` carries into `ensureSchema`. */
|
|
143
|
+
readonly checkpoints?: readonly CheckpointDeclaration[];
|
|
142
144
|
/** The event ring feeding the event tail. Absent ⇒ `events()` is empty. */
|
|
143
145
|
readonly ring?: RingBufferEvents;
|
|
144
146
|
readonly segments?: SegmentStore;
|
package/dist/admin.js
CHANGED
|
@@ -41,6 +41,7 @@ function toAdminClient(record, active, maxCommitSeq) {
|
|
|
41
41
|
export class SyncularAdmin {
|
|
42
42
|
#storage;
|
|
43
43
|
#schema;
|
|
44
|
+
#checkpoints;
|
|
44
45
|
#ring;
|
|
45
46
|
#segments;
|
|
46
47
|
#blobs;
|
|
@@ -51,6 +52,8 @@ export class SyncularAdmin {
|
|
|
51
52
|
this.#storage = options.storage;
|
|
52
53
|
if (options.schema !== undefined)
|
|
53
54
|
this.#schema = options.schema;
|
|
55
|
+
if (options.checkpoints !== undefined)
|
|
56
|
+
this.#checkpoints = options.checkpoints;
|
|
54
57
|
if (options.ring !== undefined)
|
|
55
58
|
this.#ring = options.ring;
|
|
56
59
|
if (options.segments !== undefined)
|
|
@@ -71,6 +74,9 @@ export class SyncularAdmin {
|
|
|
71
74
|
return new SyncularAdmin({
|
|
72
75
|
storage: config.storage,
|
|
73
76
|
schema: config.schema,
|
|
77
|
+
...(config.checkpoints !== undefined
|
|
78
|
+
? { checkpoints: config.checkpoints }
|
|
79
|
+
: {}),
|
|
74
80
|
segments: config.segments,
|
|
75
81
|
...(config.blobs !== undefined ? { blobs: config.blobs } : {}),
|
|
76
82
|
...(config.leases !== undefined ? { leases: config.leases.store } : {}),
|
|
@@ -138,7 +144,7 @@ export class SyncularAdmin {
|
|
|
138
144
|
async inspectRow(partition, table, rowId) {
|
|
139
145
|
const read = required(this.#storage.getRowScopes?.bind(this.#storage), 'storage');
|
|
140
146
|
if (this.#schema !== undefined) {
|
|
141
|
-
await this.#storage.ensureSchema(compileSchema(this.#schema));
|
|
147
|
+
await this.#storage.ensureSchema(compileSchema(this.#schema), this.#checkpoints);
|
|
142
148
|
}
|
|
143
149
|
const row = await read(partition, table, rowId);
|
|
144
150
|
if (row === undefined) {
|
package/dist/blob-handlers.js
CHANGED
|
@@ -149,7 +149,7 @@ async function downloadBlob(ctx, blobId) {
|
|
|
149
149
|
const schema = compileSchema(ctx.schema);
|
|
150
150
|
// A blob download can hit a fresh storage instance before any sync
|
|
151
151
|
// request has run (Workers): make sure the row tables exist first.
|
|
152
|
-
await ctx.storage.ensureSchema(schema);
|
|
152
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
153
153
|
const rows = await listRows.call(ctx.storage, ctx.partition, blobId);
|
|
154
154
|
let authorized = false;
|
|
155
155
|
for (const row of rows) {
|
package/dist/context.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ServerSchema } from './schema.js';
|
|
|
14
14
|
import type { SegmentStore } from './segment-store.js';
|
|
15
15
|
import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlConfig } from './signed-url.js';
|
|
16
16
|
import type { SqliteImageBuilder } from './sqlite-image.js';
|
|
17
|
-
import type { PartitionRegistryEntry, ServerStorage, StoredCommit } from './storage.js';
|
|
17
|
+
import type { CheckpointDeclaration, PartitionRegistryEntry, ServerStorage, StoredCommit } from './storage.js';
|
|
18
18
|
import type { CommitValidator, ValidatorRegistry } from './validate.js';
|
|
19
19
|
/** SSP2 body content type (§1.1). */
|
|
20
20
|
export declare const SSP2_CONTENT_TYPE = "application/vnd.syncular.sync.v2";
|
|
@@ -72,6 +72,13 @@ export interface RealtimeNotifier {
|
|
|
72
72
|
export interface SyncServerConfig {
|
|
73
73
|
readonly schema: ServerSchema;
|
|
74
74
|
readonly storage: ServerStorage;
|
|
75
|
+
/**
|
|
76
|
+
* RFC 0007: the backfill checkpoints this host installs. Every readiness
|
|
77
|
+
* path carries this set into `ensureSchema`. A process that omits it
|
|
78
|
+
* declares nothing, and the storage refuses to serve a partition whose
|
|
79
|
+
* stored state shows a checkpoint that is not activated.
|
|
80
|
+
*/
|
|
81
|
+
readonly checkpoints?: readonly CheckpointDeclaration[];
|
|
75
82
|
readonly segments: SegmentStore;
|
|
76
83
|
/**
|
|
77
84
|
* Blob store (§5.9). Absent ⇒ blobs unsupported: a table with a
|
package/dist/d1-storage.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
2
2
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
3
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
3
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
4
4
|
export interface D1PreparedStatement {
|
|
5
5
|
bind(...values: unknown[]): D1PreparedStatement;
|
|
6
6
|
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
@@ -36,7 +36,7 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
36
36
|
migrate(): Promise<void>;
|
|
37
37
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
38
38
|
table(name: string): CompiledTable;
|
|
39
|
-
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
39
|
+
ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
|
|
40
40
|
/** Run once per Worker invocation. Resume an incomplete result in another invocation. */
|
|
41
41
|
migrateSchema(schema: CompiledSchema, options?: {
|
|
42
42
|
readonly maxStatements?: number;
|
|
@@ -49,12 +49,20 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
49
49
|
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
50
50
|
begin(partition: string): Promise<StorageTransaction>;
|
|
51
51
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
52
|
-
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
52
|
+
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest, _checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
|
|
53
53
|
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
54
54
|
getHorizonSeq(partition: string): Promise<number>;
|
|
55
55
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
56
56
|
pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
|
|
57
57
|
getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
|
|
58
|
+
readCheckpoints(_partition: string): Promise<StoredCheckpoint[]>;
|
|
59
|
+
readServeGate(partition: string, _runningSchemaVersion: number): Promise<ServeGate>;
|
|
60
|
+
declareCheckpoint(_partition: string, _name: string, _schemaVersion: number, _nowMs: number): Promise<StoredCheckpoint>;
|
|
61
|
+
activateCheckpoint(_partition: string, _name: string, _ownerEpoch: number, _watermark: number, _sources: readonly string[], _nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
|
|
62
|
+
claimCheckpoint(_partition: string, _name: string, _schemaVersion: number, _nowMs: number): Promise<StoredCheckpoint>;
|
|
63
|
+
advanceCheckpoint(_partition: string, _name: string, _ownerEpoch: number, _watermark: number, _observedRows: number, _nowMs: number): Promise<boolean>;
|
|
64
|
+
sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
|
|
65
|
+
hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
|
|
58
66
|
getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
59
67
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
60
68
|
claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
|
package/dist/d1-storage.js
CHANGED
|
@@ -45,9 +45,9 @@ 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 { 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';
|
|
48
|
+
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, 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
|
-
import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
50
|
+
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
51
51
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
52
52
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
53
53
|
function toStoredReaction(record) {
|
|
@@ -83,10 +83,44 @@ function relationalValuesEqual(left, right) {
|
|
|
83
83
|
}
|
|
84
84
|
return Object.is(left, right);
|
|
85
85
|
}
|
|
86
|
+
/** Run one partition-bound registered query and the cursor read as one batch. */
|
|
87
|
+
async function queryAuthoritativeOn(db, partition, prepared) {
|
|
88
|
+
const results = await db.batch([
|
|
89
|
+
db.prepare(prepared.sql).bind(...prepared.params),
|
|
90
|
+
db
|
|
91
|
+
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
92
|
+
.bind(partition),
|
|
93
|
+
]);
|
|
94
|
+
const rowsResult = results[0];
|
|
95
|
+
const cursorResult = results[1];
|
|
96
|
+
if (typeof rowsResult !== 'object' ||
|
|
97
|
+
rowsResult === null ||
|
|
98
|
+
!('results' in rowsResult) ||
|
|
99
|
+
!Array.isArray(rowsResult.results) ||
|
|
100
|
+
typeof cursorResult !== 'object' ||
|
|
101
|
+
cursorResult === null ||
|
|
102
|
+
!('results' in cursorResult) ||
|
|
103
|
+
!Array.isArray(cursorResult.results)) {
|
|
104
|
+
throw new Error('D1 registered query returned an invalid batch result');
|
|
105
|
+
}
|
|
106
|
+
const cursor = cursorResult.results[0];
|
|
107
|
+
const maxCommitSeq = typeof cursor === 'object' &&
|
|
108
|
+
cursor !== null &&
|
|
109
|
+
'max_commit_seq' in cursor &&
|
|
110
|
+
typeof cursor.max_commit_seq === 'number'
|
|
111
|
+
? cursor.max_commit_seq
|
|
112
|
+
: 0;
|
|
113
|
+
return {
|
|
114
|
+
rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
|
|
115
|
+
maxCommitSeq,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
86
118
|
class D1Transaction {
|
|
87
119
|
#db;
|
|
88
120
|
#partition;
|
|
89
121
|
#resolveTable;
|
|
122
|
+
/** Storage-owned relation rewrite, bound to this transaction's partition. */
|
|
123
|
+
#prepareQuery;
|
|
90
124
|
#pushApplySerialized;
|
|
91
125
|
#buffer = [];
|
|
92
126
|
#open = true;
|
|
@@ -110,10 +144,11 @@ class D1Transaction {
|
|
|
110
144
|
* `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
|
|
111
145
|
*/
|
|
112
146
|
#pending = new Map();
|
|
113
|
-
constructor(db, partition, resolveTable, pushApplySerialized) {
|
|
147
|
+
constructor(db, partition, resolveTable, prepareQuery, pushApplySerialized) {
|
|
114
148
|
this.#db = db;
|
|
115
149
|
this.#partition = partition;
|
|
116
150
|
this.#resolveTable = resolveTable;
|
|
151
|
+
this.#prepareQuery = prepareQuery;
|
|
117
152
|
this.#pushApplySerialized = pushApplySerialized;
|
|
118
153
|
}
|
|
119
154
|
#assertOpen() {
|
|
@@ -255,6 +290,18 @@ class D1Transaction {
|
|
|
255
290
|
.sort((left, right) => left.rowId.localeCompare(right.rowId))
|
|
256
291
|
.slice(0, query.limit);
|
|
257
292
|
}
|
|
293
|
+
async queryAuthoritative(query) {
|
|
294
|
+
this.#assertOpen();
|
|
295
|
+
// D1 buffers writes until commit() and cannot overlay them on generated
|
|
296
|
+
// SQL, so a read over a written table would return committed state
|
|
297
|
+
// without this transaction's staged rows. Refuse it instead.
|
|
298
|
+
for (const key of this.#pending.keys()) {
|
|
299
|
+
if (query.tables.includes(key.slice(0, key.indexOf('\u0000')))) {
|
|
300
|
+
throw new StorageQueryError('sync.storage.query_over_staged_writes');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return queryAuthoritativeOn(this.#db, this.#partition, this.#prepareQuery(query));
|
|
304
|
+
}
|
|
258
305
|
async lockPartitionForPush() {
|
|
259
306
|
this.#assertOpen();
|
|
260
307
|
if (!this.#pushApplySerialized) {
|
|
@@ -264,6 +311,21 @@ class D1Transaction {
|
|
|
264
311
|
// write for this partition is already serialized (normally by its DO).
|
|
265
312
|
this.#pushApplyCheckpoint = this.#buffer.length;
|
|
266
313
|
}
|
|
314
|
+
advanceCheckpoint(_name, _ownerEpoch, _watermark, _observedRows, _nowMs) {
|
|
315
|
+
// D1 installs no fence and declares no checkpoints in this release.
|
|
316
|
+
return Promise.reject(new StorageQueryError('sync.storage.checkpoint_unsupported'));
|
|
317
|
+
}
|
|
318
|
+
async readServeGate(_runningSchemaVersion) {
|
|
319
|
+
this.#assertOpen();
|
|
320
|
+
const marker = await this.#db
|
|
321
|
+
.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
|
|
322
|
+
.first();
|
|
323
|
+
return {
|
|
324
|
+
storedSchemaVersion: marker?.schema_version ?? 0,
|
|
325
|
+
logEpoch: undefined,
|
|
326
|
+
pending: [],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
267
329
|
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
268
330
|
this.#assertOpen();
|
|
269
331
|
const checkpoint = this.#pushApplyCheckpoint;
|
|
@@ -495,6 +557,26 @@ _a = D1Transaction;
|
|
|
495
557
|
* binds one per app column plus the five `_sync_*` meta columns.
|
|
496
558
|
*/
|
|
497
559
|
const D1_MAX_BIND_PARAMS = 100;
|
|
560
|
+
/**
|
|
561
|
+
* The core tables `D1ServerStorage` reads or writes outside schema migration.
|
|
562
|
+
* `migrateSchema` creates them in its core phase, which runs only when the
|
|
563
|
+
* version marker advances, so a same-version open refuses a database missing
|
|
564
|
+
* one. `sync_backfill_checkpoints` and `sync_writer_fence` stay out: D1
|
|
565
|
+
* declares no checkpoints and installs no fence.
|
|
566
|
+
*/
|
|
567
|
+
const D1_CORE_TABLES = [
|
|
568
|
+
'sync_partitions',
|
|
569
|
+
'sync_partition_registry',
|
|
570
|
+
'sync_row_scopes',
|
|
571
|
+
'sync_commits',
|
|
572
|
+
'sync_changes',
|
|
573
|
+
'sync_change_scopes',
|
|
574
|
+
'sync_push_results',
|
|
575
|
+
'sync_reactions',
|
|
576
|
+
'sync_clients',
|
|
577
|
+
'sync_blob_refs',
|
|
578
|
+
'sync_tombstones',
|
|
579
|
+
];
|
|
498
580
|
export class D1ServerStorage {
|
|
499
581
|
#db;
|
|
500
582
|
#pushApplySerialized;
|
|
@@ -527,7 +609,12 @@ export class D1ServerStorage {
|
|
|
527
609
|
}
|
|
528
610
|
return table;
|
|
529
611
|
}
|
|
530
|
-
async ensureSchema(schema) {
|
|
612
|
+
async ensureSchema(schema, checkpoints) {
|
|
613
|
+
if (checkpoints !== undefined && checkpoints.length > 0) {
|
|
614
|
+
// No database-side fence can be installed atomically with schema
|
|
615
|
+
// visibility here; refuse instead of accepting a weaker guarantee.
|
|
616
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
617
|
+
}
|
|
531
618
|
if (this.#schemaVersion === schema.version) {
|
|
532
619
|
await this.#schemaDatabase().batch([]);
|
|
533
620
|
return;
|
|
@@ -582,6 +669,33 @@ export class D1ServerStorage {
|
|
|
582
669
|
const marker = await read(this.#db.prepare('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1'));
|
|
583
670
|
let claim = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
|
|
584
671
|
if (claim === null && marker?.schema_version === schema.version) {
|
|
672
|
+
assertStoredLayouts(schema, marker.layouts);
|
|
673
|
+
statementsExecuted++;
|
|
674
|
+
const present = await this.#db
|
|
675
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name IN (${placeholders(D1_CORE_TABLES.length)})`)
|
|
676
|
+
.bind(...D1_CORE_TABLES)
|
|
677
|
+
.all();
|
|
678
|
+
const presentNames = new Set(present.results.map((row) => row.name));
|
|
679
|
+
for (const name of D1_CORE_TABLES) {
|
|
680
|
+
if (!presentNames.has(name)) {
|
|
681
|
+
throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
|
|
682
|
+
table: name,
|
|
683
|
+
reason: 'missing_table',
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const table of tables) {
|
|
688
|
+
statementsExecuted++;
|
|
689
|
+
const { results } = await this.#db
|
|
690
|
+
.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
|
|
691
|
+
.all();
|
|
692
|
+
assertPhysicalColumns(table, results.map((column) => ({
|
|
693
|
+
name: column.name,
|
|
694
|
+
type: column.type,
|
|
695
|
+
notNull: column.notnull === 1,
|
|
696
|
+
primaryKeyPosition: column.pk,
|
|
697
|
+
})), 'sqlite');
|
|
698
|
+
}
|
|
585
699
|
this.#tables = schema.tables;
|
|
586
700
|
this.#schemaVersion = schema.version;
|
|
587
701
|
return { complete: true, statementsExecuted };
|
|
@@ -963,7 +1077,7 @@ export class D1ServerStorage {
|
|
|
963
1077
|
if (table === undefined)
|
|
964
1078
|
throw new StorageQueryError('sync.storage.schema_changed');
|
|
965
1079
|
return table;
|
|
966
|
-
}, this.#pushApplySerialized);
|
|
1080
|
+
}, (query) => this.#prepareAuthoritativeQuery(partition, query), this.#pushApplySerialized);
|
|
967
1081
|
}
|
|
968
1082
|
async getMaxCommitSeq(partition) {
|
|
969
1083
|
const row = await this.#db
|
|
@@ -972,41 +1086,18 @@ export class D1ServerStorage {
|
|
|
972
1086
|
.first();
|
|
973
1087
|
return row?.max_commit_seq ?? 0;
|
|
974
1088
|
}
|
|
975
|
-
async queryAuthoritative(partition, query) {
|
|
1089
|
+
async queryAuthoritative(partition, query, _checkpoints) {
|
|
1090
|
+
// D1 declares no checkpoints. `#schemaDatabase` prepends a guard that
|
|
1091
|
+
// re-checks the published marker inside this same batch, so condition 2
|
|
1092
|
+
// is already evaluated with the query (§2.4 D1 schema readiness).
|
|
976
1093
|
const db = this.#schemaDatabase();
|
|
1094
|
+
return queryAuthoritativeOn(db, partition, this.#prepareAuthoritativeQuery(partition, query));
|
|
1095
|
+
}
|
|
1096
|
+
#prepareAuthoritativeQuery(partition, query) {
|
|
977
1097
|
if (this.#tables === undefined) {
|
|
978
1098
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
979
1099
|
}
|
|
980
|
-
|
|
981
|
-
const results = await db.batch([
|
|
982
|
-
db.prepare(prepared.sql).bind(...prepared.params),
|
|
983
|
-
db
|
|
984
|
-
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
985
|
-
.bind(partition),
|
|
986
|
-
]);
|
|
987
|
-
const rowsResult = results[0];
|
|
988
|
-
const cursorResult = results[1];
|
|
989
|
-
if (typeof rowsResult !== 'object' ||
|
|
990
|
-
rowsResult === null ||
|
|
991
|
-
!('results' in rowsResult) ||
|
|
992
|
-
!Array.isArray(rowsResult.results) ||
|
|
993
|
-
typeof cursorResult !== 'object' ||
|
|
994
|
-
cursorResult === null ||
|
|
995
|
-
!('results' in cursorResult) ||
|
|
996
|
-
!Array.isArray(cursorResult.results)) {
|
|
997
|
-
throw new Error('D1 registered query returned an invalid batch result');
|
|
998
|
-
}
|
|
999
|
-
const cursor = cursorResult.results[0];
|
|
1000
|
-
const maxCommitSeq = typeof cursor === 'object' &&
|
|
1001
|
-
cursor !== null &&
|
|
1002
|
-
'max_commit_seq' in cursor &&
|
|
1003
|
-
typeof cursor.max_commit_seq === 'number'
|
|
1004
|
-
? cursor.max_commit_seq
|
|
1005
|
-
: 0;
|
|
1006
|
-
return {
|
|
1007
|
-
rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
|
|
1008
|
-
maxCommitSeq,
|
|
1009
|
-
};
|
|
1100
|
+
return bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
1010
1101
|
}
|
|
1011
1102
|
async getPartitionLogEpoch(partition) {
|
|
1012
1103
|
const row = await this.#db
|
|
@@ -1086,6 +1177,65 @@ export class D1ServerStorage {
|
|
|
1086
1177
|
.first();
|
|
1087
1178
|
return row?.seq ?? 0;
|
|
1088
1179
|
}
|
|
1180
|
+
async readCheckpoints(_partition) {
|
|
1181
|
+
// D1 supports no declared backfill checkpoints in this release: migrations
|
|
1182
|
+
// run out of band and no database-side fence can be installed atomically
|
|
1183
|
+
// with schema visibility. No checkpoint row can exist.
|
|
1184
|
+
return [];
|
|
1185
|
+
}
|
|
1186
|
+
async readServeGate(partition, _runningSchemaVersion) {
|
|
1187
|
+
// D1 has no checkpoints. The published marker is the only gate input;
|
|
1188
|
+
// `#schemaDatabase` already re-checks it per protected batch.
|
|
1189
|
+
const marker = await this.#db
|
|
1190
|
+
.prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
|
|
1191
|
+
.first();
|
|
1192
|
+
return {
|
|
1193
|
+
storedSchemaVersion: marker?.schema_version ?? 0,
|
|
1194
|
+
logEpoch: await this.getPartitionLogEpoch(partition),
|
|
1195
|
+
pending: [],
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
async declareCheckpoint(_partition, _name, _schemaVersion, _nowMs) {
|
|
1199
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1200
|
+
}
|
|
1201
|
+
async activateCheckpoint(_partition, _name, _ownerEpoch, _watermark, _sources, _nowMs) {
|
|
1202
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1203
|
+
}
|
|
1204
|
+
async claimCheckpoint(_partition, _name, _schemaVersion, _nowMs) {
|
|
1205
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1206
|
+
}
|
|
1207
|
+
async advanceCheckpoint(_partition, _name, _ownerEpoch, _watermark, _observedRows, _nowMs) {
|
|
1208
|
+
throw new StorageQueryError('sync.storage.checkpoint_unsupported');
|
|
1209
|
+
}
|
|
1210
|
+
async sourceCoverageSeq(partition, tables) {
|
|
1211
|
+
if (tables.length === 0)
|
|
1212
|
+
return 0;
|
|
1213
|
+
const tableParams = tables.map(() => '?').join(',');
|
|
1214
|
+
const row = await this.#db
|
|
1215
|
+
.prepare(`SELECT max(commit_seq) AS seq FROM sync_changes
|
|
1216
|
+
WHERE partition=? AND tbl IN (${tableParams})`)
|
|
1217
|
+
.bind(partition, ...tables)
|
|
1218
|
+
.first();
|
|
1219
|
+
return row?.seq ?? 0;
|
|
1220
|
+
}
|
|
1221
|
+
async hasSourceChangesAbove(partition, tables, seq) {
|
|
1222
|
+
// Read the horizon before the window scan: a horizon past `seq` means the
|
|
1223
|
+
// history that would answer the question is gone, so an empty scan proves
|
|
1224
|
+
// nothing and must not be reported as `clean`.
|
|
1225
|
+
const horizon = await this.getHorizonSeq(partition);
|
|
1226
|
+
if (tables.length === 0)
|
|
1227
|
+
return 'clean';
|
|
1228
|
+
const tableParams = tables.map(() => '?').join(',');
|
|
1229
|
+
const hit = await this.#db
|
|
1230
|
+
.prepare(`SELECT 1 AS hit FROM sync_changes
|
|
1231
|
+
WHERE partition=? AND tbl IN (${tableParams}) AND commit_seq>?
|
|
1232
|
+
LIMIT 1`)
|
|
1233
|
+
.bind(partition, ...tables, seq)
|
|
1234
|
+
.first();
|
|
1235
|
+
if (hit !== null)
|
|
1236
|
+
return 'changed';
|
|
1237
|
+
return horizon > seq ? 'unverifiable' : 'clean';
|
|
1238
|
+
}
|
|
1089
1239
|
async getRow(partition, table, rowId) {
|
|
1090
1240
|
const db = this.#schemaDatabase();
|
|
1091
1241
|
const record = await db
|
package/dist/errors.js
CHANGED
|
@@ -159,6 +159,16 @@ export const ERROR_CATALOG = {
|
|
|
159
159
|
recommendedAction: 'retryLater',
|
|
160
160
|
httpStatus: 500,
|
|
161
161
|
},
|
|
162
|
+
// §2.4 on a non-D1 backend: the server refuses while a declared backfill
|
|
163
|
+
// checkpoint is incomplete or the database is newer than the running build.
|
|
164
|
+
// Formerly host-only; promoted to the §10.2 catalogue so an in-flight
|
|
165
|
+
// request can be refused with a distinguishable identity.
|
|
166
|
+
'sync.schema_not_ready': {
|
|
167
|
+
category: 'internal',
|
|
168
|
+
retryable: true,
|
|
169
|
+
recommendedAction: 'retryLater',
|
|
170
|
+
httpStatus: 503,
|
|
171
|
+
},
|
|
162
172
|
'sync.too_many_operations': {
|
|
163
173
|
category: 'invalid-request',
|
|
164
174
|
retryable: false,
|
package/dist/handler.js
CHANGED
|
@@ -16,8 +16,11 @@ import { emitEvent, } from './events.js';
|
|
|
16
16
|
import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
|
|
17
17
|
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
|
|
18
18
|
import { processPushCommitWithTrace } from './push.js';
|
|
19
|
+
import { serveNotReadyError } from './readiness.js';
|
|
19
20
|
import { compileSchema } from './schema.js';
|
|
20
21
|
import { computeEffective } from './scopes.js';
|
|
22
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
23
|
+
import { serveGateRefusal, serveGateTokenChanged, } from './storage.js';
|
|
21
24
|
function validateResolvedKeys(allowed, schema) {
|
|
22
25
|
for (const key of Object.keys(allowed)) {
|
|
23
26
|
if (!schema.declaredVariables.has(key)) {
|
|
@@ -500,13 +503,56 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
|
|
|
500
503
|
const schema = compileSchema(ctx.schema);
|
|
501
504
|
// Relational row tables: create/
|
|
502
505
|
// migrate on first contact; memoized per storage instance thereafter.
|
|
503
|
-
|
|
506
|
+
try {
|
|
507
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
if (error instanceof StorageQueryError &&
|
|
511
|
+
error.code === 'sync.storage.checkpoint_incomplete') {
|
|
512
|
+
const refusal = serveGateRefusal(await ctx.storage.readServeGate(ctx.partition, schema.version), schema.version, ctx.checkpoints);
|
|
513
|
+
throw refusal !== undefined ? serveNotReadyError(refusal) : error;
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
// RFC 0007 serve gate: re-read stored state on every request so an
|
|
518
|
+
// already-serving process notices a migration or a newly declared
|
|
519
|
+
// checkpoint. Read after `touchAuthenticatedPartition` establishes the
|
|
520
|
+
// partition's log epoch, so the entry token is the epoch this request
|
|
521
|
+
// serves under. Nothing has escaped at this point; the push transaction
|
|
522
|
+
// re-evaluates the gate under the partition lock.
|
|
504
523
|
const registry = await touchAuthenticatedPartition(ctx);
|
|
524
|
+
const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
|
|
525
|
+
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
526
|
+
if (refusal !== undefined)
|
|
527
|
+
throw serveNotReadyError(refusal);
|
|
505
528
|
const plan = await planRequest(request, ctx, schema, registry);
|
|
506
|
-
if (events === undefined)
|
|
507
|
-
return streamResponse(plan, ctx, schema);
|
|
508
529
|
const report = { outcome: 'ok' };
|
|
509
|
-
|
|
530
|
+
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
531
|
+
// builds the whole response (its data reads) before yielding the first
|
|
532
|
+
// frame, then compares the gate token across those reads. Nothing escapes
|
|
533
|
+
// before the verification, and the buffered size is bounded by the request's
|
|
534
|
+
// pull limits and the server's inline segment cap. Every request verifies,
|
|
535
|
+
// including a mixed push+pull: a write that already applied when the token
|
|
536
|
+
// changed is durable and replayable under the same commit id / idempotency
|
|
537
|
+
// key, so refusing the request is safe (proved in the serve-gate lane).
|
|
538
|
+
const verified = async function* () {
|
|
539
|
+
const buffered = [];
|
|
540
|
+
for await (const chunk of streamResponse(plan, ctx, schema, report)) {
|
|
541
|
+
buffered.push(chunk);
|
|
542
|
+
}
|
|
543
|
+
const after = await ctx.storage.readServeGate(ctx.partition, schema.version);
|
|
544
|
+
const changed = serveGateTokenChanged(gate, after);
|
|
545
|
+
const afterRefusal = serveGateRefusal(after, schema.version, ctx.checkpoints);
|
|
546
|
+
if (afterRefusal !== undefined)
|
|
547
|
+
throw serveNotReadyError(afterRefusal);
|
|
548
|
+
if (changed !== undefined)
|
|
549
|
+
throw serveNotReadyError(changed);
|
|
550
|
+
for (const chunk of buffered)
|
|
551
|
+
yield chunk;
|
|
552
|
+
};
|
|
553
|
+
if (events === undefined)
|
|
554
|
+
return verified();
|
|
555
|
+
return instrumentedStream(verified(), ctx, events, plan, report, bytes.length, startedAtMs);
|
|
510
556
|
}
|
|
511
557
|
/** Counts response bytes and emits `request.handled` when the stream
|
|
512
558
|
* finishes — including a thrown host failure mid-stream (fail loud). */
|
package/dist/operations.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type AuthoritativeRelationPlan } from './authoritative-query.js';
|
|
2
2
|
import { type RemoteOperationResponse, type RowValue } from '@syncular/core';
|
|
3
3
|
import type { SyncRequestContext } from './context.js';
|
|
4
|
-
import type { AuthoritativeQueryValue } from './storage.js';
|
|
4
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, AuthoritativeQueryValue } from './storage.js';
|
|
5
5
|
import { type ValidateRow } from './validate.js';
|
|
6
6
|
export interface RemoteQueryDependency {
|
|
7
7
|
readonly table: string;
|
|
@@ -63,6 +63,12 @@ export interface RemoteCommandContext {
|
|
|
63
63
|
readonly operationId: string;
|
|
64
64
|
readonly requestId: string;
|
|
65
65
|
getRow(table: string, rowId: string): Promise<ValidateRow | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Run a generated registered query on the command's push transaction
|
|
68
|
+
* (§6.7). The storage binds every relation to the partition; unlike
|
|
69
|
+
* `getRow`, the rows are not filtered by the actor's scopes.
|
|
70
|
+
*/
|
|
71
|
+
queryAuthoritative(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
66
72
|
}
|
|
67
73
|
export interface RemoteCommandOptions<Input> {
|
|
68
74
|
readonly authorize: (context: RemoteOperationAuthContext, input: Input) => boolean | Promise<boolean>;
|
package/dist/operations.js
CHANGED
|
@@ -5,6 +5,7 @@ import { SyncError, syncError } from './errors.js';
|
|
|
5
5
|
import { processPushOperationsWithTrace } from './push.js';
|
|
6
6
|
import { compileSchema } from './schema.js';
|
|
7
7
|
import { authorizeWrite } from './scopes.js';
|
|
8
|
+
import { transactionQuery } from './storage-query.js';
|
|
8
9
|
import { toValidateRow } from './validate.js';
|
|
9
10
|
function scopeAllowed(allowed, variable, value) {
|
|
10
11
|
const values = allowed[variable];
|
|
@@ -185,7 +186,7 @@ export function registerRemoteQuery(descriptor, options) {
|
|
|
185
186
|
if (plan === undefined) {
|
|
186
187
|
throw syncError('operation.invalid_request', 'selected SQL has no generated relation plan; regenerate queries');
|
|
187
188
|
}
|
|
188
|
-
await ctx.storage.ensureSchema(schema);
|
|
189
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
189
190
|
const prefix = 'SELECT * FROM (';
|
|
190
191
|
let result;
|
|
191
192
|
try {
|
|
@@ -200,7 +201,7 @@ export function registerRemoteQuery(descriptor, options) {
|
|
|
200
201
|
},
|
|
201
202
|
params: [...descriptor.bind(params), options.maxRows + 1],
|
|
202
203
|
tables: descriptor.tables,
|
|
203
|
-
});
|
|
204
|
+
}, ctx.checkpoints);
|
|
204
205
|
}
|
|
205
206
|
catch (error) {
|
|
206
207
|
if (error instanceof SyncError)
|
|
@@ -293,6 +294,7 @@ function commandContext(ctx, tx, resolved, schema, clientId, operationId, reques
|
|
|
293
294
|
}
|
|
294
295
|
return toValidateRow(table.columns, decodeRow(table.columns, stored.payload));
|
|
295
296
|
},
|
|
297
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
296
298
|
};
|
|
297
299
|
}
|
|
298
300
|
/** Register custom command code that plans one ordinary Syncular commit. */
|
|
@@ -318,7 +320,7 @@ export function registerRemoteCommand(descriptor, options) {
|
|
|
318
320
|
}
|
|
319
321
|
const resolved = { ok: true, allowed };
|
|
320
322
|
const schema = compileSchema(ctx.schema);
|
|
321
|
-
await ctx.storage.ensureSchema(schema);
|
|
323
|
+
await ctx.storage.ensureSchema(schema, ctx.checkpoints);
|
|
322
324
|
const processed = await processPushOperationsWithTrace(ctx, schema, resolved, JSON.stringify(['remote-command', ctx.actorId, clientId]), JSON.stringify([descriptor.id, requestId]), async (tx) => commandOperations(await options.run(commandContext(ctx, tx, resolved, schema, clientId, descriptor.id, requestId), input), schema));
|
|
323
325
|
return {
|
|
324
326
|
revision: 1,
|