@syncular/server 0.30.5 → 0.30.7
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 +10 -0
- package/dist/context.d.ts +3 -1
- package/dist/handler.js +34 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pg-executor-pglite.js +7 -1
- package/dist/pull.js +16 -7
- package/dist/push.d.ts +2 -2
- package/dist/push.js +19 -11
- package/dist/readiness.d.ts +1 -1
- package/dist/readiness.js +2 -0
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +23 -4
- package/dist/schema-window.d.ts +7 -0
- package/dist/schema-window.js +46 -0
- package/dist/segment-download.js +3 -1
- package/dist/sqlite-image.js +4 -2
- package/dist/sqlite-segment-store.js +4 -2
- package/dist/sqlite-storage.js +22 -16
- package/dist/storage-errors.d.ts +3 -0
- package/dist/storage-errors.js +15 -0
- package/dist/storage.d.ts +2 -0
- package/package.json +2 -2
- package/src/context.ts +3 -1
- package/src/handler.ts +43 -8
- package/src/index.ts +2 -0
- package/src/pg-executor-pglite.ts +6 -1
- package/src/pull.ts +37 -7
- package/src/push.ts +22 -6
- package/src/readiness.ts +6 -1
- package/src/realtime.ts +48 -4
- package/src/schema-window.ts +72 -0
- package/src/segment-download.ts +5 -1
- package/src/sqlite-image.ts +4 -2
- package/src/sqlite-segment-store.ts +4 -2
- package/src/sqlite-storage.ts +21 -15
- package/src/storage-errors.ts +19 -0
- package/src/storage.ts +2 -0
package/README.md
CHANGED
|
@@ -1232,3 +1232,13 @@ maxima, preserve registration fields, and require a matching actor and current
|
|
|
1232
1232
|
partition log epoch. Leave missing records unchanged. SQLite, Postgres, and D1
|
|
1233
1233
|
perform one update without reading or serializing the subscription list. HTTP
|
|
1234
1234
|
registration keeps its existing cursor and subscription replacement rules.
|
|
1235
|
+
|
|
1236
|
+
## Reviewed schema windows
|
|
1237
|
+
|
|
1238
|
+
Pass `schemaWindow: [compileSchema(schema), compileSchema(previousSchema)]` to
|
|
1239
|
+
serve the prior codec through the current storage and validation rules. Entries
|
|
1240
|
+
are newest first, with the compiled current schema first. Added tables and
|
|
1241
|
+
appended nullable columns are supported. Removed/renamed columns, changed codecs,
|
|
1242
|
+
primary keys, references or scopes reject the window before serving. Hosts must
|
|
1243
|
+
exclude semantically incompatible prior versions. The [schema upgrade guide](https://syncular.dev/concepts-schema-upgrades/#serving-a-compatibility-window)
|
|
1244
|
+
owns the pull, segment, push and realtime contract.
|
package/dist/context.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { SyncularErrorHandler } from './errors.js';
|
|
|
11
11
|
import type { SyncularServerEvents } from './events.js';
|
|
12
12
|
import type { LeaseStore } from './lease-store.js';
|
|
13
13
|
import type { AnyReactionPlanner } from './reactions.js';
|
|
14
|
-
import type { ServerSchema } from './schema.js';
|
|
14
|
+
import type { ServerSchema, CompiledSchema } from './schema.js';
|
|
15
15
|
import type { SegmentStore } from './segment-store.js';
|
|
16
16
|
import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlConfig } from './signed-url.js';
|
|
17
17
|
import type { SqliteImageBuilder } from './sqlite-image.js';
|
|
@@ -72,6 +72,8 @@ export interface RealtimeNotifier {
|
|
|
72
72
|
}
|
|
73
73
|
export interface SyncServerConfig {
|
|
74
74
|
readonly schema: ServerSchema;
|
|
75
|
+
/** Current compiled schema followed by the supported previous versions, newest first. */
|
|
76
|
+
readonly schemaWindow?: readonly CompiledSchema[];
|
|
75
77
|
readonly storage: ServerStorage;
|
|
76
78
|
/**
|
|
77
79
|
* RFC 0007: the backfill checkpoints this host installs. Every readiness
|
package/dist/handler.js
CHANGED
|
@@ -18,6 +18,7 @@ import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, prepareSecti
|
|
|
18
18
|
import { processPushCommitWithTrace } from './push.js';
|
|
19
19
|
import { serveNotReadyError } from './readiness.js';
|
|
20
20
|
import { compileSchema } from './schema.js';
|
|
21
|
+
import { schemaWindowOf, projectRowPayload } from './schema-window.js';
|
|
21
22
|
import { computeEffective } from './scopes.js';
|
|
22
23
|
import { StorageQueryError } from './storage-errors.js';
|
|
23
24
|
import { serveGateRefusal, serveGateTokenChanged, } from './storage.js';
|
|
@@ -195,7 +196,7 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
195
196
|
}
|
|
196
197
|
validated.push({ frame, table: frame.table });
|
|
197
198
|
}
|
|
198
|
-
const { resolved, leaseToEmit } = await resolveOnce(ctx, header.clientId, schema);
|
|
199
|
+
const { resolved, leaseToEmit } = await resolveOnce(ctx, header.clientId, compileSchema(ctx.schema));
|
|
199
200
|
const subscriptions = validated.map(({ frame }) => {
|
|
200
201
|
const table = schema.tables.get(frame.table);
|
|
201
202
|
if (table === undefined)
|
|
@@ -291,8 +292,8 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
291
292
|
report.outcome = 'schema_floor';
|
|
292
293
|
yield encodeResponseFrame({
|
|
293
294
|
type: 'RESP_HEADER',
|
|
294
|
-
requiredSchemaVersion: schema.version,
|
|
295
|
-
latestSchemaVersion: schema.version,
|
|
295
|
+
requiredSchemaVersion: schemaWindowOf(ctx.schema, ctx.schemaWindow).at(-1).version,
|
|
296
|
+
latestSchemaVersion: ctx.schema.version,
|
|
296
297
|
...(plan.wireVersion >= 2
|
|
297
298
|
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
298
299
|
: {}),
|
|
@@ -302,7 +303,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
302
303
|
}
|
|
303
304
|
yield encodeResponseFrame({
|
|
304
305
|
type: 'RESP_HEADER',
|
|
305
|
-
latestSchemaVersion: schema.version,
|
|
306
|
+
latestSchemaVersion: ctx.schema.version,
|
|
306
307
|
...(plan.wireVersion >= 2
|
|
307
308
|
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
308
309
|
: {}),
|
|
@@ -335,8 +336,27 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
335
336
|
try {
|
|
336
337
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
337
338
|
for (const push of plan.pushes) {
|
|
338
|
-
const processed = await processPushCommitWithTrace(ctx, schema, plan.resolved, plan.header.clientId, push);
|
|
339
|
-
const
|
|
339
|
+
const processed = await processPushCommitWithTrace(ctx, compileSchema(ctx.schema), plan.resolved, plan.header.clientId, push, schema);
|
|
340
|
+
const frame = {
|
|
341
|
+
...processed.frame,
|
|
342
|
+
results: processed.frame.results.map((result) => {
|
|
343
|
+
if (result.status !== 'conflict')
|
|
344
|
+
return result;
|
|
345
|
+
const operation = push.operations[result.opIndex];
|
|
346
|
+
const target = schema.tables.get(operation.table);
|
|
347
|
+
const current = compileSchema(ctx.schema).tables.get(operation.table);
|
|
348
|
+
return {
|
|
349
|
+
...result,
|
|
350
|
+
serverRow: projectRowPayload(current, target, result.serverRow),
|
|
351
|
+
conflictColumns: result.conflictColumns
|
|
352
|
+
.slice(0, Math.ceil(target.columns.length / 8))
|
|
353
|
+
.map((byte, i) => i === Math.ceil(target.columns.length / 8) - 1 &&
|
|
354
|
+
target.columns.length % 8 !== 0
|
|
355
|
+
? byte & ((1 << (target.columns.length % 8)) - 1)
|
|
356
|
+
: byte),
|
|
357
|
+
};
|
|
358
|
+
}),
|
|
359
|
+
};
|
|
340
360
|
if (events !== undefined) {
|
|
341
361
|
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
342
362
|
}
|
|
@@ -419,6 +439,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
419
439
|
id: s.frame.id,
|
|
420
440
|
table: s.frame.table,
|
|
421
441
|
scopes: s.frame.scopes,
|
|
442
|
+
schemaVersion: schema.version,
|
|
422
443
|
}))
|
|
423
444
|
: [...(previous?.subscriptions ?? [])];
|
|
424
445
|
const cursor = cursors.length > 0 ? Math.min(...cursors) : (previous?.cursor ?? -1);
|
|
@@ -529,7 +550,12 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
|
|
|
529
550
|
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
530
551
|
if (refusal !== undefined)
|
|
531
552
|
throw serveNotReadyError(refusal);
|
|
532
|
-
const
|
|
553
|
+
const window = schemaWindowOf(ctx.schema, ctx.schemaWindow);
|
|
554
|
+
const header = request.frames[0];
|
|
555
|
+
const selected = window.find((candidate) => header?.type === 'REQ_HEADER' &&
|
|
556
|
+
candidate.version === header.schemaVersion);
|
|
557
|
+
const served = selected ?? schema;
|
|
558
|
+
const plan = await planRequest(request, ctx, served, registry);
|
|
533
559
|
const report = { outcome: 'ok' };
|
|
534
560
|
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
535
561
|
// builds the whole response (its data reads) before yielding the first
|
|
@@ -541,7 +567,7 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
|
|
|
541
567
|
// key, so refusing the request is safe (proved in the serve-gate lane).
|
|
542
568
|
const verified = async function* () {
|
|
543
569
|
const buffered = [];
|
|
544
|
-
for await (const chunk of streamResponse(plan, ctx,
|
|
570
|
+
for await (const chunk of streamResponse(plan, ctx, served, report)) {
|
|
545
571
|
buffered.push(chunk);
|
|
546
572
|
}
|
|
547
573
|
const after = await ctx.storage.readServeGate(ctx.partition, schema.version);
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainRollbackFailure } from './storage-errors.js';
|
|
1
2
|
function queryable(db) {
|
|
2
3
|
return {
|
|
3
4
|
async query(text, params) {
|
|
@@ -32,7 +33,12 @@ export function pgliteExecutor(db) {
|
|
|
32
33
|
return result;
|
|
33
34
|
}
|
|
34
35
|
catch (error) {
|
|
35
|
-
|
|
36
|
+
try {
|
|
37
|
+
await like.exec('ROLLBACK');
|
|
38
|
+
}
|
|
39
|
+
catch (rollbackError) {
|
|
40
|
+
retainRollbackFailure(error, rollbackError);
|
|
41
|
+
}
|
|
36
42
|
throw error;
|
|
37
43
|
}
|
|
38
44
|
}
|
package/dist/pull.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Pull: incremental commit delivery, cursors, bootstrap segments
|
|
3
3
|
* (SPEC.md §4, §5).
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { encodeRowsSegment, } from '@syncular/core';
|
|
6
6
|
import { clockOf, limitsOf } from './context.js';
|
|
7
7
|
import { compileSchema, } from './schema.js';
|
|
8
8
|
import { scopeDigest } from './scopes.js';
|
|
9
9
|
import { issueSegmentUrl } from './signed-url.js';
|
|
10
|
+
import { projectRowPayload, projectRowValues } from './schema-window.js';
|
|
10
11
|
// One artifact build per owning storage pair and complete immutable identity.
|
|
11
12
|
const imageBuilds = new WeakMap();
|
|
12
13
|
/**
|
|
@@ -69,7 +70,7 @@ function parseBootstrapToken(raw, table) {
|
|
|
69
70
|
return undefined;
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
|
-
function commitFrame(table, commit) {
|
|
73
|
+
function commitFrame(table, commit, current, target) {
|
|
73
74
|
const changes = commit.changes.map((change) => ({
|
|
74
75
|
tableIndex: 0,
|
|
75
76
|
rowId: change.rowId,
|
|
@@ -78,7 +79,9 @@ function commitFrame(table, commit) {
|
|
|
78
79
|
? { rowVersion: change.rowVersion }
|
|
79
80
|
: {}),
|
|
80
81
|
scopes: change.scopes,
|
|
81
|
-
...(change.payload !== undefined
|
|
82
|
+
...(change.payload !== undefined
|
|
83
|
+
? { row: projectRowPayload(current, target, change.payload) }
|
|
84
|
+
: {}),
|
|
82
85
|
}));
|
|
83
86
|
return {
|
|
84
87
|
type: 'COMMIT',
|
|
@@ -220,7 +223,10 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
220
223
|
scopeDigest: digest,
|
|
221
224
|
rowBatches: (async function* () {
|
|
222
225
|
rowCount += probe.length;
|
|
223
|
-
yield probe
|
|
226
|
+
yield probe.map((row) => ({
|
|
227
|
+
...row,
|
|
228
|
+
payload: projectRowPayload(compileSchema(ctx.schema).tables.get(plan.table.name), plan.table, row.payload),
|
|
229
|
+
}));
|
|
224
230
|
let afterRowId = probe[probe.length - 1].rowId;
|
|
225
231
|
for (;;) {
|
|
226
232
|
const rows = await storage.scanRows(partition, {
|
|
@@ -230,7 +236,10 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
230
236
|
limit: 5_000,
|
|
231
237
|
});
|
|
232
238
|
rowCount += rows.length;
|
|
233
|
-
yield rows
|
|
239
|
+
yield rows.map((row) => ({
|
|
240
|
+
...row,
|
|
241
|
+
payload: projectRowPayload(compileSchema(ctx.schema).tables.get(plan.table.name), plan.table, row.payload),
|
|
242
|
+
}));
|
|
234
243
|
const last = rows[rows.length - 1];
|
|
235
244
|
if (rows.length < 5_000 || last === undefined)
|
|
236
245
|
break;
|
|
@@ -285,7 +294,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
285
294
|
// §5.2: every row record carries the row's current server_version.
|
|
286
295
|
const decoded = pageRows.map((row) => ({
|
|
287
296
|
serverVersion: row.serverVersion,
|
|
288
|
-
values:
|
|
297
|
+
values: projectRowValues(compileSchema(ctx.schema).tables.get(plan.table.name), plan.table, row.payload),
|
|
289
298
|
}));
|
|
290
299
|
const bytes = encodeRowsSegment({
|
|
291
300
|
table: plan.table.name,
|
|
@@ -525,7 +534,7 @@ export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepar
|
|
|
525
534
|
delivered + commit.changes.length > limits.limitCommits) {
|
|
526
535
|
break;
|
|
527
536
|
}
|
|
528
|
-
yield commitFrame(sub.table, commit);
|
|
537
|
+
yield commitFrame(sub.table, commit, compileSchema(ctx.schema).tables.get(sub.table), plan.table);
|
|
529
538
|
delivered += commit.changes.length;
|
|
530
539
|
deliveredCommits += 1;
|
|
531
540
|
lastDeliveredSeq = commit.commitSeq;
|
package/dist/push.d.ts
CHANGED
|
@@ -42,10 +42,10 @@ export declare function processPushCommit(ctx: SyncRequestContext, schema: Compi
|
|
|
42
42
|
* rejected replays as `status: rejected`; this companion result preserves the
|
|
43
43
|
* cache provenance needed by structured events and server helpers.
|
|
44
44
|
*/
|
|
45
|
-
export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<ProcessedPushCommit>;
|
|
45
|
+
export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame, wireSchema?: CompiledSchema): Promise<ProcessedPushCommit>;
|
|
46
46
|
/**
|
|
47
47
|
* Shared serialized apply path for SSP2 commits and authoritative commands.
|
|
48
48
|
* The builder runs after the partition lock and idempotency re-check, so its
|
|
49
49
|
* reads and the returned operations share the transaction that is committed.
|
|
50
50
|
*/
|
|
51
|
-
export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly PushOperation[]
|
|
51
|
+
export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly PushOperation[]>, wireSchema?: CompiledSchema): Promise<ProcessedPushCommit>;
|
package/dist/push.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainRollbackFailure } from './storage-errors.js';
|
|
1
2
|
/**
|
|
2
3
|
* Push apply (SPEC.md §6) with §3.4 write-path authorization.
|
|
3
4
|
*
|
|
@@ -161,9 +162,10 @@ async function mergeCrdtColumns(table, values, storedValues, present, opIndex, m
|
|
|
161
162
|
}
|
|
162
163
|
return { changed };
|
|
163
164
|
}
|
|
164
|
-
async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, actorId) {
|
|
165
|
+
async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, actorId) {
|
|
165
166
|
const table = schema.tables.get(op.table);
|
|
166
|
-
|
|
167
|
+
const wireTable = wireSchema.tables.get(op.table);
|
|
168
|
+
if (table === undefined || wireTable === undefined) {
|
|
167
169
|
return errorRecord(opIndex, 'sync.unknown_table', `table ${JSON.stringify(op.table)} is not handled by this server`);
|
|
168
170
|
}
|
|
169
171
|
if (!resolved.ok) {
|
|
@@ -237,7 +239,9 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
237
239
|
}
|
|
238
240
|
let sparse;
|
|
239
241
|
try {
|
|
240
|
-
sparse = decodeSparseRow(
|
|
242
|
+
sparse = decodeSparseRow(wireTable.columns, wireTable.primaryKeyIndex, payload);
|
|
243
|
+
while (sparse.length < table.columns.length)
|
|
244
|
+
sparse.push(stored === undefined ? null : undefined);
|
|
241
245
|
}
|
|
242
246
|
catch (error) {
|
|
243
247
|
return errorRecord(opIndex, 'sync.invalid_request', `row payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -830,15 +834,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
830
834
|
* rejected replays as `status: rejected`; this companion result preserves the
|
|
831
835
|
* cache provenance needed by structured events and server helpers.
|
|
832
836
|
*/
|
|
833
|
-
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
|
|
834
|
-
return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations);
|
|
837
|
+
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame, wireSchema = schema) {
|
|
838
|
+
return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations, wireSchema);
|
|
835
839
|
}
|
|
836
840
|
/**
|
|
837
841
|
* Shared serialized apply path for SSP2 commits and authoritative commands.
|
|
838
842
|
* The builder runs after the partition lock and idempotency re-check, so its
|
|
839
843
|
* reads and the returned operations share the transaction that is committed.
|
|
840
844
|
*/
|
|
841
|
-
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations) {
|
|
845
|
+
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema) {
|
|
842
846
|
const { storage, partition } = ctx;
|
|
843
847
|
let persisted;
|
|
844
848
|
try {
|
|
@@ -936,7 +940,7 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
936
940
|
const op = operations[opIndex];
|
|
937
941
|
if (op === undefined)
|
|
938
942
|
continue;
|
|
939
|
-
const outcome = await applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, ctx.actorId);
|
|
943
|
+
const outcome = await applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, ctx.actorId);
|
|
940
944
|
if (outcome.kind === 'terminate') {
|
|
941
945
|
terminated = outcome.record;
|
|
942
946
|
break;
|
|
@@ -1099,14 +1103,18 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1099
1103
|
try {
|
|
1100
1104
|
await tx.rollback();
|
|
1101
1105
|
}
|
|
1102
|
-
catch {
|
|
1103
|
-
|
|
1104
|
-
// otherwise mask it.
|
|
1106
|
+
catch (rollbackError) {
|
|
1107
|
+
retainRollbackFailure(finalizationError, rollbackError);
|
|
1105
1108
|
}
|
|
1106
1109
|
throw finalizationError;
|
|
1107
1110
|
}
|
|
1108
1111
|
}
|
|
1109
|
-
|
|
1112
|
+
try {
|
|
1113
|
+
await tx.rollback();
|
|
1114
|
+
}
|
|
1115
|
+
catch (rollbackError) {
|
|
1116
|
+
retainRollbackFailure(error, rollbackError);
|
|
1117
|
+
}
|
|
1110
1118
|
throw error;
|
|
1111
1119
|
}
|
|
1112
1120
|
}
|
package/dist/readiness.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export declare class SyncServerReadinessError extends Error {
|
|
|
29
29
|
* exposes only a stable code, phase, and schema version; operators can inspect
|
|
30
30
|
* `cause` locally for the table/column or storage diagnostic.
|
|
31
31
|
*/
|
|
32
|
-
export declare function ensureSyncServerReady(config: Pick<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>): Promise<void>;
|
|
32
|
+
export declare function ensureSyncServerReady(config: Pick<SyncServerConfig, 'schema' | 'schemaWindow' | 'storage' | 'checkpoints'>): Promise<void>;
|
|
33
33
|
/**
|
|
34
34
|
* RFC 0007: refuse a serve path that observed a closed gate. The projection
|
|
35
35
|
* name travels in structured details, never in the message.
|
package/dist/readiness.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { syncError } from './errors.js';
|
|
2
2
|
import { compileSchema } from './schema.js';
|
|
3
|
+
import { schemaWindowOf } from './schema-window.js';
|
|
3
4
|
export const SYNC_SERVER_READINESS_ERROR_CODE = 'sync.schema_not_ready';
|
|
4
5
|
export class SyncServerReadinessError extends Error {
|
|
5
6
|
name = 'SyncServerReadinessError';
|
|
@@ -23,6 +24,7 @@ export async function ensureSyncServerReady(config) {
|
|
|
23
24
|
let compiled;
|
|
24
25
|
try {
|
|
25
26
|
compiled = compileSchema(config.schema);
|
|
27
|
+
schemaWindowOf(config.schema, config.schemaWindow);
|
|
26
28
|
}
|
|
27
29
|
catch (cause) {
|
|
28
30
|
throw new SyncServerReadinessError({
|
package/dist/realtime.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { type PresenceKind, type ScopeMap, type WakeReason } from '@syncular/cor
|
|
|
20
20
|
import type { SyncRequestContext, SyncServerConfig } from './context.js';
|
|
21
21
|
import { type SyncularErrorHandler } from './errors.js';
|
|
22
22
|
import { type SyncularServerEvents } from './events.js';
|
|
23
|
+
import { type CompiledTable } from './schema.js';
|
|
23
24
|
import type { ServerStorage, StoredCommit } from './storage.js';
|
|
24
25
|
/**
|
|
25
26
|
* Realtime adds fanout/presence tuning to the canonical sync-server config;
|
|
@@ -62,6 +63,7 @@ export interface RealtimeConnectOptions {
|
|
|
62
63
|
readonly closeSocket?: () => void;
|
|
63
64
|
}
|
|
64
65
|
interface Registration {
|
|
66
|
+
readonly codec: CompiledTable;
|
|
65
67
|
readonly id: string;
|
|
66
68
|
readonly table: string;
|
|
67
69
|
readonly effective: ScopeMap;
|
|
@@ -172,6 +174,7 @@ export declare class RealtimeHub {
|
|
|
172
174
|
/** §8.6 presence registry — ephemeral, in-memory, never persisted. */
|
|
173
175
|
readonly presence: PresenceRegistry;
|
|
174
176
|
constructor(config: RealtimeHubConfig);
|
|
177
|
+
projectPayload(registration: Registration, payload: Uint8Array): Uint8Array;
|
|
175
178
|
get sessionCount(): number;
|
|
176
179
|
/** §8.6.2 published-document size cap (bytes). */
|
|
177
180
|
get maxPresenceBytes(): number;
|
package/dist/realtime.js
CHANGED
|
@@ -21,7 +21,8 @@ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPar
|
|
|
21
21
|
import { adapterSyncError, reportError, SyncError, syncError, } from './errors.js';
|
|
22
22
|
import { emitEvent } from './events.js';
|
|
23
23
|
import { createSyncResponseStream } from './handler.js';
|
|
24
|
-
import { compileSchema } from './schema.js';
|
|
24
|
+
import { compileSchema, } from './schema.js';
|
|
25
|
+
import { schemaWindowOf, projectRowPayload } from './schema-window.js';
|
|
25
26
|
import { computeEffective, matchesEffective, } from './scopes.js';
|
|
26
27
|
const DEFAULT_MAX_DELTA_BYTES = 1024 * 1024;
|
|
27
28
|
const DEFAULT_MAX_PRESENCE_BYTES = 16 * 1024;
|
|
@@ -608,7 +609,9 @@ export class RealtimeSession {
|
|
|
608
609
|
? { rowVersion: change.rowVersion }
|
|
609
610
|
: {}),
|
|
610
611
|
scopes: change.scopes,
|
|
611
|
-
...(change.payload !== undefined
|
|
612
|
+
...(change.payload !== undefined
|
|
613
|
+
? { row: this.#hub.projectPayload(registration, change.payload) }
|
|
614
|
+
: {}),
|
|
612
615
|
}));
|
|
613
616
|
if (changes.length > 0)
|
|
614
617
|
sections.push({ registration, changes });
|
|
@@ -706,6 +709,10 @@ export class RealtimeHub {
|
|
|
706
709
|
constructor(config) {
|
|
707
710
|
this.#config = config;
|
|
708
711
|
this.#schema = compileSchema(config.schema);
|
|
712
|
+
schemaWindowOf(config.schema, config.schemaWindow);
|
|
713
|
+
}
|
|
714
|
+
projectPayload(registration, payload) {
|
|
715
|
+
return projectRowPayload(this.#schema.tables.get(registration.table), registration.codec, payload);
|
|
709
716
|
}
|
|
710
717
|
get sessionCount() {
|
|
711
718
|
return this.#sessions.size;
|
|
@@ -775,7 +782,12 @@ export class RealtimeHub {
|
|
|
775
782
|
}
|
|
776
783
|
const registrations = [];
|
|
777
784
|
for (const subscription of record?.subscriptions ?? []) {
|
|
778
|
-
const
|
|
785
|
+
const window = schemaWindowOf(this.#config.schema, this.#config.schemaWindow);
|
|
786
|
+
const selected = subscription.schemaVersion === undefined &&
|
|
787
|
+
this.#config.schemaWindow === undefined
|
|
788
|
+
? this.#schema
|
|
789
|
+
: window.find((schema) => schema.version === subscription.schemaVersion);
|
|
790
|
+
const table = selected?.tables.get(subscription.table);
|
|
779
791
|
if (table === undefined)
|
|
780
792
|
continue;
|
|
781
793
|
const keysValid = Object.keys(subscription.scopes).every((key) => table.declaredVariables.has(key));
|
|
@@ -786,6 +798,7 @@ export class RealtimeHub {
|
|
|
786
798
|
continue;
|
|
787
799
|
registrations.push({
|
|
788
800
|
id: subscription.id,
|
|
801
|
+
codec: table,
|
|
789
802
|
table: subscription.table,
|
|
790
803
|
effective: outcome.effective,
|
|
791
804
|
});
|
|
@@ -808,6 +821,9 @@ export class RealtimeHub {
|
|
|
808
821
|
partition: identity.partition,
|
|
809
822
|
actorId: identity.actorId,
|
|
810
823
|
schema: this.#config.schema,
|
|
824
|
+
...(this.#config.schemaWindow !== undefined
|
|
825
|
+
? { schemaWindow: this.#config.schemaWindow }
|
|
826
|
+
: {}),
|
|
811
827
|
storage: this.#config.storage,
|
|
812
828
|
segments,
|
|
813
829
|
resolveScopes: this.#config.resolveScopes,
|
|
@@ -896,7 +912,10 @@ export class RealtimeHub {
|
|
|
896
912
|
clientId: options.clientId,
|
|
897
913
|
cursor,
|
|
898
914
|
latestCursor: latestSeq,
|
|
899
|
-
requiresSync: record === undefined ||
|
|
915
|
+
requiresSync: record === undefined ||
|
|
916
|
+
cursor < latestSeq ||
|
|
917
|
+
(this.#config.schemaWindow !== undefined &&
|
|
918
|
+
record.subscriptions.some((subscription) => !schemaWindowOf(this.#config.schema, this.#config.schemaWindow).some((schema) => schema.version === subscription.schemaVersion))),
|
|
900
919
|
timestamp: clock(),
|
|
901
920
|
},
|
|
902
921
|
}));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type RowValue } from '@syncular/core';
|
|
2
|
+
import { type CompiledSchema, type CompiledTable, type ServerSchema } from './schema.js';
|
|
3
|
+
/** A host-reviewed newest-first window. Storage and validation always use current. */
|
|
4
|
+
export declare function schemaWindowOf(current: ServerSchema, configured?: readonly CompiledSchema[]): readonly CompiledSchema[];
|
|
5
|
+
/** Canonical stored bytes never leave the server under another version's codec. */
|
|
6
|
+
export declare function projectRowValues(current: CompiledTable, target: CompiledTable, payload: Uint8Array): RowValue[];
|
|
7
|
+
export declare function projectRowPayload(current: CompiledTable, target: CompiledTable, payload: Uint8Array): Uint8Array;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { decodeRow, encodeRow } from '@syncular/core';
|
|
2
|
+
import { compileSchema, } from './schema.js';
|
|
3
|
+
const validatedWindows = new WeakMap();
|
|
4
|
+
/** A host-reviewed newest-first window. Storage and validation always use current. */
|
|
5
|
+
export function schemaWindowOf(current, configured) {
|
|
6
|
+
const head = compileSchema(current);
|
|
7
|
+
if (configured === undefined)
|
|
8
|
+
return [head];
|
|
9
|
+
if (validatedWindows.get(configured) === head)
|
|
10
|
+
return configured;
|
|
11
|
+
if (configured.length === 0 || configured[0] !== head)
|
|
12
|
+
throw new Error('sync.schema_window.invalid_head');
|
|
13
|
+
let previous = head.version + 1;
|
|
14
|
+
for (const schema of configured) {
|
|
15
|
+
if (schema.version >= previous)
|
|
16
|
+
throw new Error('sync.schema_window.invalid_order');
|
|
17
|
+
previous = schema.version;
|
|
18
|
+
for (const table of schema.tables.values()) {
|
|
19
|
+
const next = head.tables.get(table.name);
|
|
20
|
+
if (next === undefined ||
|
|
21
|
+
table.columns.length > next.columns.length ||
|
|
22
|
+
table.columns.some((column, i) => JSON.stringify(column) !== JSON.stringify(next.columns[i])) ||
|
|
23
|
+
next.columns
|
|
24
|
+
.slice(table.columns.length)
|
|
25
|
+
.some((column) => !column.nullable) ||
|
|
26
|
+
table.primaryKeyIndex !== next.primaryKeyIndex ||
|
|
27
|
+
JSON.stringify(table.scopePatterns) !==
|
|
28
|
+
JSON.stringify(next.scopePatterns) ||
|
|
29
|
+
JSON.stringify(table.references) !== JSON.stringify(next.references)) {
|
|
30
|
+
throw new Error('sync.schema_window.incompatible_schema');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
validatedWindows.set(configured, head);
|
|
35
|
+
return configured;
|
|
36
|
+
}
|
|
37
|
+
/** Canonical stored bytes never leave the server under another version's codec. */
|
|
38
|
+
export function projectRowValues(current, target, payload) {
|
|
39
|
+
const values = decodeRow(current.columns, payload);
|
|
40
|
+
return values.slice(0, target.columns.length);
|
|
41
|
+
}
|
|
42
|
+
export function projectRowPayload(current, target, payload) {
|
|
43
|
+
return current === target
|
|
44
|
+
? payload
|
|
45
|
+
: encodeRow(target.columns, projectRowValues(current, target, payload));
|
|
46
|
+
}
|
package/dist/segment-download.js
CHANGED
|
@@ -2,6 +2,7 @@ import { clockOf, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './contex
|
|
|
2
2
|
import { SyncError, syncError } from './errors.js';
|
|
3
3
|
import { emitEvent } from './events.js';
|
|
4
4
|
import { compileSchema } from './schema.js';
|
|
5
|
+
import { schemaWindowOf } from './schema-window.js';
|
|
5
6
|
import { computeEffective, scopeDigest } from './scopes.js';
|
|
6
7
|
import { bytesStream, publicationRecord, } from './segment-store.js';
|
|
7
8
|
function parseScopesHeader(header) {
|
|
@@ -184,7 +185,8 @@ async function authorizePublication(ctx, request, logEpoch, stored) {
|
|
|
184
185
|
// earlier valid publication of the same bytes exists.
|
|
185
186
|
const schema = compileSchema(ctx.schema);
|
|
186
187
|
const candidates = live.filter((publication) => {
|
|
187
|
-
const
|
|
188
|
+
const served = schemaWindowOf(ctx.schema, ctx.schemaWindow).find((candidate) => candidate.version === publication.schemaVersion);
|
|
189
|
+
const table = served?.tables.get(publication.table);
|
|
188
190
|
return (table !== undefined &&
|
|
189
191
|
Object.entries(requested).every(([key, values]) => table.declaredVariables.has(key) && !values.includes('*')));
|
|
190
192
|
});
|
package/dist/sqlite-image.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rollbackAfterError } from './storage-errors.js';
|
|
1
2
|
/**
|
|
2
3
|
* SQLite-image segment generation (SPEC.md §5.3): a complete SQLite
|
|
3
4
|
* database file carrying one table's whole effective-scope snapshot at
|
|
@@ -79,7 +80,8 @@ export async function writeSqliteImage(db, input) {
|
|
|
79
80
|
db.exec('COMMIT');
|
|
80
81
|
}
|
|
81
82
|
catch (error) {
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
rollbackAfterError(() => {
|
|
84
|
+
db.exec('ROLLBACK');
|
|
85
|
+
}, error);
|
|
84
86
|
}
|
|
85
87
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rollbackAfterError } from './storage-errors.js';
|
|
1
2
|
/**
|
|
2
3
|
* SQLite-backed segment store over the shared synchronous driver.
|
|
3
4
|
*/
|
|
@@ -137,8 +138,9 @@ export class SqliteSegmentStore {
|
|
|
137
138
|
this.db.exec('COMMIT');
|
|
138
139
|
}
|
|
139
140
|
catch (error) {
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
rollbackAfterError(() => {
|
|
142
|
+
this.db.exec('ROLLBACK');
|
|
143
|
+
}, error);
|
|
142
144
|
}
|
|
143
145
|
return record;
|
|
144
146
|
}
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainRollbackFailure, rollbackAfterError } from './storage-errors.js';
|
|
1
2
|
import { validateCommitPruneQuery } from './prune.js';
|
|
2
3
|
import { StorageQueryError } from './storage-errors.js';
|
|
3
4
|
import { serveGateRefusal } from './storage.js';
|
|
@@ -229,9 +230,8 @@ class SqliteTransaction {
|
|
|
229
230
|
try {
|
|
230
231
|
this.#storage.db.exec('ROLLBACK');
|
|
231
232
|
}
|
|
232
|
-
catch {
|
|
233
|
-
|
|
234
|
-
// mask it.
|
|
233
|
+
catch (rollbackError) {
|
|
234
|
+
retainRollbackFailure(error, rollbackError);
|
|
235
235
|
}
|
|
236
236
|
throw error;
|
|
237
237
|
}
|
|
@@ -433,8 +433,9 @@ END`);
|
|
|
433
433
|
this.db.exec('COMMIT');
|
|
434
434
|
}
|
|
435
435
|
catch (error) {
|
|
436
|
-
|
|
437
|
-
|
|
436
|
+
rollbackAfterError(() => {
|
|
437
|
+
this.db.exec('ROLLBACK');
|
|
438
|
+
}, error);
|
|
438
439
|
}
|
|
439
440
|
});
|
|
440
441
|
}
|
|
@@ -486,8 +487,9 @@ END`);
|
|
|
486
487
|
this.db.exec('COMMIT');
|
|
487
488
|
}
|
|
488
489
|
catch (error) {
|
|
489
|
-
|
|
490
|
-
|
|
490
|
+
rollbackAfterError(() => {
|
|
491
|
+
this.db.exec('ROLLBACK');
|
|
492
|
+
}, error);
|
|
491
493
|
}
|
|
492
494
|
return {
|
|
493
495
|
partition,
|
|
@@ -624,9 +626,10 @@ END`);
|
|
|
624
626
|
return result;
|
|
625
627
|
}
|
|
626
628
|
catch (error) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
629
|
+
rollbackAfterError(() => {
|
|
630
|
+
if (open)
|
|
631
|
+
this.db.exec('ROLLBACK');
|
|
632
|
+
}, error);
|
|
630
633
|
}
|
|
631
634
|
finally {
|
|
632
635
|
release();
|
|
@@ -690,8 +693,9 @@ END`);
|
|
|
690
693
|
};
|
|
691
694
|
}
|
|
692
695
|
catch (error) {
|
|
693
|
-
|
|
694
|
-
|
|
696
|
+
rollbackAfterError(() => {
|
|
697
|
+
this.db.exec('ROLLBACK');
|
|
698
|
+
}, error);
|
|
695
699
|
}
|
|
696
700
|
});
|
|
697
701
|
}
|
|
@@ -771,8 +775,9 @@ END`);
|
|
|
771
775
|
return toStoredCheckpoint(row);
|
|
772
776
|
}
|
|
773
777
|
catch (error) {
|
|
774
|
-
|
|
775
|
-
|
|
778
|
+
rollbackAfterError(() => {
|
|
779
|
+
this.db.exec('ROLLBACK');
|
|
780
|
+
}, error);
|
|
776
781
|
}
|
|
777
782
|
});
|
|
778
783
|
}
|
|
@@ -810,8 +815,9 @@ END`);
|
|
|
810
815
|
return Number(updated.changes) === 1 ? 'activated' : 'stale';
|
|
811
816
|
}
|
|
812
817
|
catch (error) {
|
|
813
|
-
|
|
814
|
-
|
|
818
|
+
rollbackAfterError(() => {
|
|
819
|
+
this.db.exec('ROLLBACK');
|
|
820
|
+
}, error);
|
|
815
821
|
}
|
|
816
822
|
});
|
|
817
823
|
}
|