@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/push.ts
CHANGED
|
@@ -52,7 +52,10 @@ import type {
|
|
|
52
52
|
StoredPushResult,
|
|
53
53
|
StoredRow,
|
|
54
54
|
} from './storage';
|
|
55
|
+
import { serveGateRefusal } from './storage';
|
|
55
56
|
import { StorageConstraintError } from './storage-errors';
|
|
57
|
+
import { transactionQuery } from './storage-query';
|
|
58
|
+
import { serveNotReadyError } from './readiness';
|
|
56
59
|
import type {
|
|
57
60
|
CommitValidationReader,
|
|
58
61
|
CommitValidator,
|
|
@@ -184,6 +187,7 @@ async function runValidator(
|
|
|
184
187
|
values: readonly RowValue[] | undefined,
|
|
185
188
|
storedValues: readonly RowValue[] | undefined,
|
|
186
189
|
opIndex: number,
|
|
190
|
+
tx: StorageTransaction,
|
|
187
191
|
partition: string,
|
|
188
192
|
actorId: string,
|
|
189
193
|
): Promise<TerminatingOutcome | undefined> {
|
|
@@ -204,7 +208,11 @@ async function runValidator(
|
|
|
204
208
|
? toValidateRow(table.columns, storedValues)
|
|
205
209
|
: undefined,
|
|
206
210
|
},
|
|
207
|
-
{
|
|
211
|
+
{
|
|
212
|
+
actorId,
|
|
213
|
+
partition,
|
|
214
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
215
|
+
},
|
|
208
216
|
);
|
|
209
217
|
} catch (error) {
|
|
210
218
|
if (error instanceof ValidationRejection) {
|
|
@@ -361,6 +369,7 @@ async function applyOperation(
|
|
|
361
369
|
undefined,
|
|
362
370
|
storedValues,
|
|
363
371
|
opIndex,
|
|
372
|
+
tx,
|
|
364
373
|
partition,
|
|
365
374
|
actorId,
|
|
366
375
|
);
|
|
@@ -551,6 +560,7 @@ async function applyOperation(
|
|
|
551
560
|
values,
|
|
552
561
|
storedValues,
|
|
553
562
|
opIndex,
|
|
563
|
+
tx,
|
|
554
564
|
partition,
|
|
555
565
|
actorId,
|
|
556
566
|
);
|
|
@@ -681,6 +691,7 @@ async function applyOperation(
|
|
|
681
691
|
values,
|
|
682
692
|
undefined,
|
|
683
693
|
opIndex,
|
|
694
|
+
tx,
|
|
684
695
|
partition,
|
|
685
696
|
actorId,
|
|
686
697
|
);
|
|
@@ -834,6 +845,7 @@ function commitValidationReader(
|
|
|
834
845
|
serverVersion: stored.serverVersion,
|
|
835
846
|
}));
|
|
836
847
|
},
|
|
848
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
837
849
|
};
|
|
838
850
|
}
|
|
839
851
|
|
|
@@ -1082,6 +1094,7 @@ async function enforceReferences(
|
|
|
1082
1094
|
undefined,
|
|
1083
1095
|
storedValues,
|
|
1084
1096
|
item.originOpIndex,
|
|
1097
|
+
tx,
|
|
1085
1098
|
partition,
|
|
1086
1099
|
actorId,
|
|
1087
1100
|
);
|
|
@@ -1115,6 +1128,7 @@ async function enforceReferences(
|
|
|
1115
1128
|
values,
|
|
1116
1129
|
storedValues,
|
|
1117
1130
|
item.originOpIndex,
|
|
1131
|
+
tx,
|
|
1118
1132
|
partition,
|
|
1119
1133
|
actorId,
|
|
1120
1134
|
);
|
|
@@ -1356,6 +1370,15 @@ export async function processPushOperationsWithTrace(
|
|
|
1356
1370
|
);
|
|
1357
1371
|
}
|
|
1358
1372
|
await lockPartitionForPush();
|
|
1373
|
+
// RFC 0007: evaluate the gate on this transaction's own connection while
|
|
1374
|
+
// the partition lock is held, so the migration check and the write share
|
|
1375
|
+
// one transaction and no migration can interleave in between.
|
|
1376
|
+
const gate = await tx.readServeGate(schema.version);
|
|
1377
|
+
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
1378
|
+
if (refusal !== undefined) {
|
|
1379
|
+
await tx.rollback();
|
|
1380
|
+
throw serveNotReadyError(refusal);
|
|
1381
|
+
}
|
|
1359
1382
|
// The optimistic lookup above may have raced another delivery. Re-check
|
|
1360
1383
|
// only after acquiring partition serialization and before any operation
|
|
1361
1384
|
// read, validation, merge, or staged write. The re-check runs on the
|
|
@@ -1551,6 +1574,13 @@ export async function processPushOperationsWithTrace(
|
|
|
1551
1574
|
}
|
|
1552
1575
|
}
|
|
1553
1576
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
1577
|
+
// RFC 0007 fanout decision: the notification carries the durable commit
|
|
1578
|
+
// entry itself (this commit's `changes`), not a projection read or a pull
|
|
1579
|
+
// window, so fanning it out before the request's buffered read-verify can
|
|
1580
|
+
// never serve a mixed result. Each peer's next pull is gated at the
|
|
1581
|
+
// storage seam; a refused mixed push+pull is safe because the push is
|
|
1582
|
+
// durable and replayable under its commit id. This is commit-entry
|
|
1583
|
+
// durability, not gate inheritance.
|
|
1554
1584
|
await ctx.realtime.notifyCommit(partition, {
|
|
1555
1585
|
commitSeq,
|
|
1556
1586
|
createdAtMs,
|
package/src/readiness.ts
CHANGED
|
@@ -7,12 +7,17 @@
|
|
|
7
7
|
* a structured failure that cannot be confused with request authentication.
|
|
8
8
|
*/
|
|
9
9
|
import type { SyncServerConfig } from './context';
|
|
10
|
+
import { type SyncError, syncError } from './errors';
|
|
10
11
|
import { type CompiledSchema, compileSchema } from './schema';
|
|
12
|
+
import type { ServeGateRefusal } from './storage';
|
|
11
13
|
|
|
12
14
|
export const SYNC_SERVER_READINESS_ERROR_CODE =
|
|
13
15
|
'sync.schema_not_ready' as const;
|
|
14
16
|
|
|
15
|
-
export type SyncServerReadinessPhase =
|
|
17
|
+
export type SyncServerReadinessPhase =
|
|
18
|
+
| 'schema_compile'
|
|
19
|
+
| 'storage_migration'
|
|
20
|
+
| 'backfill_checkpoint';
|
|
16
21
|
|
|
17
22
|
export class SyncServerReadinessError extends Error {
|
|
18
23
|
override readonly name = 'SyncServerReadinessError';
|
|
@@ -42,7 +47,7 @@ export class SyncServerReadinessError extends Error {
|
|
|
42
47
|
* `cause` locally for the table/column or storage diagnostic.
|
|
43
48
|
*/
|
|
44
49
|
export async function ensureSyncServerReady(
|
|
45
|
-
config: Pick<SyncServerConfig, 'schema' | 'storage'>,
|
|
50
|
+
config: Pick<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>,
|
|
46
51
|
): Promise<void> {
|
|
47
52
|
let compiled: CompiledSchema;
|
|
48
53
|
try {
|
|
@@ -55,12 +60,42 @@ export async function ensureSyncServerReady(
|
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
try {
|
|
58
|
-
await config.storage.ensureSchema(compiled);
|
|
63
|
+
await config.storage.ensureSchema(compiled, config.checkpoints);
|
|
59
64
|
} catch (cause) {
|
|
65
|
+
// RFC 0007: a declared-but-incomplete checkpoint is a barrier refusal, not
|
|
66
|
+
// an ordinary migration failure — hosts distinguish it by `phase` while the
|
|
67
|
+
// client-visible identity stays `sync.schema_not_ready`.
|
|
68
|
+
const phase: SyncServerReadinessPhase =
|
|
69
|
+
(cause as { code?: unknown } | null | undefined)?.code ===
|
|
70
|
+
'sync.storage.checkpoint_incomplete'
|
|
71
|
+
? 'backfill_checkpoint'
|
|
72
|
+
: 'storage_migration';
|
|
60
73
|
throw new SyncServerReadinessError({
|
|
61
|
-
phase
|
|
74
|
+
phase,
|
|
62
75
|
schemaVersion: config.schema.version,
|
|
63
76
|
cause,
|
|
64
77
|
});
|
|
65
78
|
}
|
|
66
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* RFC 0007: refuse a serve path that observed a closed gate. The projection
|
|
83
|
+
* name travels in structured details, never in the message.
|
|
84
|
+
*/
|
|
85
|
+
export function serveNotReadyError(refusal: ServeGateRefusal): SyncError {
|
|
86
|
+
return syncError(
|
|
87
|
+
SYNC_SERVER_READINESS_ERROR_CODE,
|
|
88
|
+
'server schema is not ready for this request',
|
|
89
|
+
refusal.kind === 'checkpoint'
|
|
90
|
+
? JSON.stringify({
|
|
91
|
+
partition: refusal.partition,
|
|
92
|
+
projection: refusal.name,
|
|
93
|
+
})
|
|
94
|
+
: refusal.kind === 'epoch'
|
|
95
|
+
? JSON.stringify({ logEpochChanged: true })
|
|
96
|
+
: JSON.stringify({
|
|
97
|
+
storedSchemaVersion: refusal.stored,
|
|
98
|
+
runningSchemaVersion: refusal.running,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}
|
package/src/realtime.ts
CHANGED
|
@@ -600,7 +600,28 @@ export class RealtimeSession {
|
|
|
600
600
|
// begin its next round as soon as END *arrives* (§8.7 rule 3), so
|
|
601
601
|
// in-flight must end with the send, not with generator cleanup.
|
|
602
602
|
const iterator = stream[Symbol.asyncIterator]();
|
|
603
|
-
let step
|
|
603
|
+
let step: IteratorResult<Uint8Array>;
|
|
604
|
+
try {
|
|
605
|
+
step = await iterator.next();
|
|
606
|
+
} catch (error) {
|
|
607
|
+
// The buffered read-verify refusal is thrown on the generator's first
|
|
608
|
+
// `next()`, before any chunk is yielded (the generator builds the whole
|
|
609
|
+
// response, then verifies). Nothing has escaped, so it is delivered as
|
|
610
|
+
// the same §8.4 ERROR frame HTTP returns rather than a silent close. A
|
|
611
|
+
// throw after the first chunk still takes the §8.7 violation path.
|
|
612
|
+
if (error instanceof DecodeError || error instanceof SyncError) {
|
|
613
|
+
const sync =
|
|
614
|
+
error instanceof SyncError
|
|
615
|
+
? error
|
|
616
|
+
: syncError(error.code, error.message);
|
|
617
|
+
finishRound();
|
|
618
|
+
await this.#sendRoundChunk(
|
|
619
|
+
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
604
625
|
while (!step.done) {
|
|
605
626
|
const chunk = step.value;
|
|
606
627
|
step = await iterator.next();
|
package/src/relational-rows.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from '@syncular/core';
|
|
56
56
|
import type { CompiledSchema, CompiledTable, IndexSchema } from './schema';
|
|
57
57
|
import type { StoredRow } from './storage';
|
|
58
|
+
import { StorageQueryError } from './storage-errors';
|
|
58
59
|
|
|
59
60
|
export type RelationalDialect = 'sqlite' | 'postgres';
|
|
60
61
|
|
|
@@ -192,38 +193,112 @@ export function tableColumnNames(table: CompiledTable): string[] {
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
/**
|
|
195
|
-
* The
|
|
196
|
-
*
|
|
196
|
+
* The storage-internal columns of every synced table, in `createTableDdl`
|
|
197
|
+
* order (app columns sit between the row id and the version). The same-version
|
|
198
|
+
* physical check compares a table against exactly these declarations.
|
|
199
|
+
*/
|
|
200
|
+
function syncMetaColumns(dialect: RelationalDialect): readonly {
|
|
201
|
+
readonly name: string;
|
|
202
|
+
readonly type: string;
|
|
203
|
+
readonly notNull: boolean;
|
|
204
|
+
}[] {
|
|
205
|
+
const payloadType = dialect === 'postgres' ? 'BYTEA' : 'BLOB';
|
|
206
|
+
return [
|
|
207
|
+
{ name: SYNC_PARTITION_COLUMN, type: 'TEXT', notNull: true },
|
|
208
|
+
{ name: SYNC_ROW_ID_COLUMN, type: 'TEXT', notNull: true },
|
|
209
|
+
{
|
|
210
|
+
name: SYNC_VERSION_COLUMN,
|
|
211
|
+
type: dialect === 'postgres' ? 'BIGINT' : 'INTEGER',
|
|
212
|
+
notNull: true,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: SYNC_SCOPES_COLUMN,
|
|
216
|
+
type: dialect === 'postgres' ? 'JSONB' : 'TEXT',
|
|
217
|
+
notNull: true,
|
|
218
|
+
},
|
|
219
|
+
{ name: SYNC_PAYLOAD_COLUMN, type: payloadType, notNull: true },
|
|
220
|
+
{ name: SYNC_COLUMN_VERSIONS_COLUMN, type: payloadType, notNull: false },
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** One physical column of a synced table, read from the database catalog. */
|
|
225
|
+
export interface PhysicalColumn {
|
|
226
|
+
readonly name: string;
|
|
227
|
+
/** Declared type (SQLite) or `format_type` (PostgreSQL); compared case-insensitively. */
|
|
228
|
+
readonly type: string;
|
|
229
|
+
readonly notNull: boolean;
|
|
230
|
+
/** 1-based position in the primary key; 0 when the column is not in it. */
|
|
231
|
+
readonly primaryKeyPosition: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Refuse a same-version database whose physical table the row store cannot
|
|
236
|
+
* read or write. The persisted marker's layouts describe the codec's app
|
|
197
237
|
* columns only and its version number is the schema's, not the database's, so
|
|
198
|
-
* a same-version database
|
|
199
|
-
* version-only bump whose ALTER never ran
|
|
200
|
-
*
|
|
238
|
+
* a same-version database whose storage-internal meta columns drifted (a
|
|
239
|
+
* version-only bump whose ALTER never ran, or a table written by another
|
|
240
|
+
* build) is only visible in the catalog. Every configured column must exist;
|
|
241
|
+
* the meta columns must also carry the type, nullability, and primary-key
|
|
242
|
+
* position `createTableDdl` declares. App-column types and nullability stay
|
|
243
|
+
* with the stored layouts: migration-added app columns are nullable by design.
|
|
244
|
+
* Fails closed without writing a migration.
|
|
201
245
|
*
|
|
202
|
-
* `
|
|
203
|
-
*
|
|
246
|
+
* `columns` must be resolved the way the unqualified row queries resolve the
|
|
247
|
+
* table: on PostgreSQL, through the session `search_path`, not
|
|
204
248
|
* `current_schema()`. A search path whose first schema is not where the
|
|
205
249
|
* configured tables live is outside the supported contract: DDL is created in
|
|
206
250
|
* that first schema, so `ensureSchema` already writes shadow tables there.
|
|
207
251
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* persisted layouts answer for app columns.
|
|
252
|
+
* Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
|
|
253
|
+
* this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
|
|
254
|
+
* and column additions, independently of the version marker.
|
|
212
255
|
*/
|
|
213
256
|
export function assertPhysicalColumns(
|
|
214
257
|
table: CompiledTable,
|
|
215
|
-
|
|
258
|
+
columns: readonly PhysicalColumn[],
|
|
259
|
+
dialect: RelationalDialect,
|
|
216
260
|
): void {
|
|
217
|
-
|
|
218
|
-
throw new
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
261
|
+
const refuse = (details: Record<string, string>): never => {
|
|
262
|
+
throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
|
|
263
|
+
table: table.name,
|
|
264
|
+
...details,
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
if (columns.length === 0) refuse({ reason: 'missing_table' });
|
|
268
|
+
const byName = new Map(columns.map((column) => [column.name, column]));
|
|
222
269
|
for (const name of tableColumnNames(table)) {
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
);
|
|
270
|
+
if (!byName.has(name)) refuse({ column: name, reason: 'missing_column' });
|
|
271
|
+
}
|
|
272
|
+
for (const expected of syncMetaColumns(dialect)) {
|
|
273
|
+
const actual = byName.get(expected.name);
|
|
274
|
+
if (actual === undefined) continue;
|
|
275
|
+
if (actual.type.toUpperCase() !== expected.type) {
|
|
276
|
+
refuse({
|
|
277
|
+
column: expected.name,
|
|
278
|
+
reason: 'type',
|
|
279
|
+
expected: expected.type,
|
|
280
|
+
actual: actual.type,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (actual.notNull !== expected.notNull) {
|
|
284
|
+
refuse({
|
|
285
|
+
column: expected.name,
|
|
286
|
+
reason: 'nullability',
|
|
287
|
+
expected: expected.notNull ? 'not_null' : 'nullable',
|
|
288
|
+
actual: actual.notNull ? 'not_null' : 'nullable',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const column of columns) {
|
|
293
|
+
const expected =
|
|
294
|
+
column.name === SYNC_PARTITION_COLUMN
|
|
295
|
+
? 1
|
|
296
|
+
: column.name === SYNC_ROW_ID_COLUMN
|
|
297
|
+
? 2
|
|
298
|
+
: 0;
|
|
299
|
+
if (column.primaryKeyPosition !== expected) {
|
|
300
|
+
refuse({ column: column.name, reason: 'primary_key' });
|
|
301
|
+
}
|
|
227
302
|
}
|
|
228
303
|
}
|
|
229
304
|
|
|
@@ -232,22 +307,20 @@ export function createTableDdl(
|
|
|
232
307
|
table: CompiledTable,
|
|
233
308
|
dialect: RelationalDialect,
|
|
234
309
|
): string {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
310
|
+
const [partition, rowId, ...trailing] = syncMetaColumns(dialect).map(
|
|
311
|
+
(column) =>
|
|
312
|
+
`${quoteIdent(column.name)} ${column.type}${column.notNull ? ' NOT NULL' : ''}`,
|
|
313
|
+
);
|
|
238
314
|
const defs = [
|
|
239
|
-
|
|
240
|
-
|
|
315
|
+
partition,
|
|
316
|
+
rowId,
|
|
241
317
|
...(table.materialize
|
|
242
318
|
? table.columns.map((column) => {
|
|
243
319
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
244
320
|
return `${quoteIdent(column.name)} ${columnSqlType(column, dialect)}${notNull}`;
|
|
245
321
|
})
|
|
246
322
|
: []),
|
|
247
|
-
|
|
248
|
-
`${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
|
|
249
|
-
`${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
|
|
250
|
-
`${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
|
|
323
|
+
...trailing,
|
|
251
324
|
`PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
|
|
252
325
|
];
|
|
253
326
|
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
|
|
@@ -660,6 +733,48 @@ export function parseLayouts(json: string | null | undefined): StoredLayouts {
|
|
|
660
733
|
return JSON.parse(json) as StoredLayouts;
|
|
661
734
|
}
|
|
662
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Version equality is not layout equality: a marker written by another build
|
|
738
|
+
* at the same version describes rows the running codec cannot decode. Refuse
|
|
739
|
+
* when the stored layouts disagree with the configured schema, naming the
|
|
740
|
+
* first table (and column) that differs.
|
|
741
|
+
*/
|
|
742
|
+
export function assertStoredLayouts(
|
|
743
|
+
schema: CompiledSchema,
|
|
744
|
+
storedJson: string | null | undefined,
|
|
745
|
+
): void {
|
|
746
|
+
const storedLayouts = parseLayouts(storedJson);
|
|
747
|
+
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
748
|
+
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
749
|
+
const stored = storedLayouts[tableName];
|
|
750
|
+
const columnCount = Math.max(columns.length, stored?.length ?? 0);
|
|
751
|
+
for (let index = 0; index < columnCount; index++) {
|
|
752
|
+
const expected = columns[index];
|
|
753
|
+
const actual = stored?.[index];
|
|
754
|
+
if (
|
|
755
|
+
expected !== undefined &&
|
|
756
|
+
actual !== undefined &&
|
|
757
|
+
actual.name === expected.name &&
|
|
758
|
+
actual.type === expected.type &&
|
|
759
|
+
actual.nullable === expected.nullable
|
|
760
|
+
) {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
|
|
764
|
+
table: tableName,
|
|
765
|
+
column: expected?.name ?? actual?.name ?? '',
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
for (const tableName of Object.keys(storedLayouts)) {
|
|
770
|
+
if (!(tableName in configuredLayouts)) {
|
|
771
|
+
throw new StorageQueryError('sync.storage.stored_layout_mismatch', {
|
|
772
|
+
table: tableName,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
663
778
|
/**
|
|
664
779
|
* Tables present in the stored layout but absent from the configured head
|
|
665
780
|
* schema. A schema-version bump retires their relational current-row tables;
|
package/src/segment-download.ts
CHANGED
|
@@ -196,6 +196,22 @@ async function downloadSegment(
|
|
|
196
196
|
}
|
|
197
197
|
const publication = candidates[candidates.length - 1]!;
|
|
198
198
|
|
|
199
|
+
// RFC 0007: a descriptor minted before this partition declared a backfill
|
|
200
|
+
// checkpoint is not a complete window once the projection becomes
|
|
201
|
+
// checkpointed. SPEC §9 mints segments per schema version, so refuse and
|
|
202
|
+
// let the client re-pull for a fresh descriptor.
|
|
203
|
+
const checkpoints = await ctx.storage.readCheckpoints(ctx.partition);
|
|
204
|
+
if (
|
|
205
|
+
checkpoints.some(
|
|
206
|
+
(checkpoint) => checkpoint.schemaVersion > publication.schemaVersion,
|
|
207
|
+
)
|
|
208
|
+
) {
|
|
209
|
+
throw syncError(
|
|
210
|
+
'sync.segment_expired',
|
|
211
|
+
'segment predates the partition backfill checkpoint — re-pull (§5.1)',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
199
215
|
return {
|
|
200
216
|
// The returned record is projected onto the selected publication, so its
|
|
201
217
|
// `scopeDigests`/`publications` compatibility view cannot union a grant
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS sync_commits(
|
|
|
66
66
|
partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,
|
|
67
67
|
client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
|
|
68
68
|
actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,
|
|
69
|
+
writer_version INTEGER,
|
|
69
70
|
PRIMARY KEY(partition, commit_seq)
|
|
70
71
|
);
|
|
71
72
|
CREATE INDEX IF NOT EXISTS sync_commits_by_time
|
|
@@ -127,6 +128,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
|
127
128
|
commit_seq INTEGER NOT NULL,
|
|
128
129
|
PRIMARY KEY(partition, tbl, row_id)
|
|
129
130
|
);
|
|
131
|
+
CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
|
|
132
|
+
partition TEXT NOT NULL, name TEXT NOT NULL,
|
|
133
|
+
schema_version INTEGER NOT NULL,
|
|
134
|
+
state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
|
|
135
|
+
watermark INTEGER NOT NULL DEFAULT 0,
|
|
136
|
+
owner_epoch INTEGER NOT NULL DEFAULT 0,
|
|
137
|
+
observed_rows INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
updated_at_ms INTEGER NOT NULL,
|
|
139
|
+
PRIMARY KEY(partition, name)
|
|
140
|
+
);
|
|
141
|
+
CREATE TABLE IF NOT EXISTS sync_writer_fence(
|
|
142
|
+
partition TEXT PRIMARY KEY,
|
|
143
|
+
required_writer_version INTEGER NOT NULL
|
|
144
|
+
);
|
|
130
145
|
`;
|
|
131
146
|
|
|
132
147
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|