@syncular/server 0.30.4 → 0.30.6
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/pull.js +16 -7
- package/dist/push.d.ts +2 -2
- package/dist/push.js +10 -7
- 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/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/pull.ts +37 -7
- package/src/push.ts +14 -2
- 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/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
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
|
@@ -161,9 +161,10 @@ async function mergeCrdtColumns(table, values, storedValues, present, opIndex, m
|
|
|
161
161
|
}
|
|
162
162
|
return { changed };
|
|
163
163
|
}
|
|
164
|
-
async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, actorId) {
|
|
164
|
+
async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, actorId) {
|
|
165
165
|
const table = schema.tables.get(op.table);
|
|
166
|
-
|
|
166
|
+
const wireTable = wireSchema.tables.get(op.table);
|
|
167
|
+
if (table === undefined || wireTable === undefined) {
|
|
167
168
|
return errorRecord(opIndex, 'sync.unknown_table', `table ${JSON.stringify(op.table)} is not handled by this server`);
|
|
168
169
|
}
|
|
169
170
|
if (!resolved.ok) {
|
|
@@ -237,7 +238,9 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
237
238
|
}
|
|
238
239
|
let sparse;
|
|
239
240
|
try {
|
|
240
|
-
sparse = decodeSparseRow(
|
|
241
|
+
sparse = decodeSparseRow(wireTable.columns, wireTable.primaryKeyIndex, payload);
|
|
242
|
+
while (sparse.length < table.columns.length)
|
|
243
|
+
sparse.push(stored === undefined ? null : undefined);
|
|
241
244
|
}
|
|
242
245
|
catch (error) {
|
|
243
246
|
return errorRecord(opIndex, 'sync.invalid_request', `row payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -830,15 +833,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
830
833
|
* rejected replays as `status: rejected`; this companion result preserves the
|
|
831
834
|
* cache provenance needed by structured events and server helpers.
|
|
832
835
|
*/
|
|
833
|
-
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
|
|
834
|
-
return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations);
|
|
836
|
+
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame, wireSchema = schema) {
|
|
837
|
+
return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations, wireSchema);
|
|
835
838
|
}
|
|
836
839
|
/**
|
|
837
840
|
* Shared serialized apply path for SSP2 commits and authoritative commands.
|
|
838
841
|
* The builder runs after the partition lock and idempotency re-check, so its
|
|
839
842
|
* reads and the returned operations share the transaction that is committed.
|
|
840
843
|
*/
|
|
841
|
-
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations) {
|
|
844
|
+
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema) {
|
|
842
845
|
const { storage, partition } = ctx;
|
|
843
846
|
let persisted;
|
|
844
847
|
try {
|
|
@@ -936,7 +939,7 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
936
939
|
const op = operations[opIndex];
|
|
937
940
|
if (op === undefined)
|
|
938
941
|
continue;
|
|
939
|
-
const outcome = await applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, ctx.actorId);
|
|
942
|
+
const outcome = await applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, ctx.actorId);
|
|
940
943
|
if (outcome.kind === 'terminate') {
|
|
941
944
|
terminated = outcome.record;
|
|
942
945
|
break;
|
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/storage.d.ts
CHANGED
|
@@ -219,6 +219,8 @@ export interface StoredPushResult {
|
|
|
219
219
|
readonly results: readonly PushOperationResult[];
|
|
220
220
|
}
|
|
221
221
|
export interface ClientSubscription {
|
|
222
|
+
/** Codec accepted by the last pull; absent on records written before schema windows. */
|
|
223
|
+
readonly schemaVersion?: number;
|
|
222
224
|
readonly id: string;
|
|
223
225
|
readonly table: string;
|
|
224
226
|
/** Requested scopes of the client's most recent pull (§8.1). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.6",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.30.
|
|
71
|
+
"@syncular/core": "0.30.6"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/context.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { SyncularErrorHandler } from './errors';
|
|
|
11
11
|
import type { SyncularServerEvents } from './events';
|
|
12
12
|
import type { LeaseStore } from './lease-store';
|
|
13
13
|
import type { AnyReactionPlanner } from './reactions';
|
|
14
|
-
import type { ServerSchema } from './schema';
|
|
14
|
+
import type { ServerSchema, CompiledSchema } from './schema';
|
|
15
15
|
import type { SegmentStore } from './segment-store';
|
|
16
16
|
import type {
|
|
17
17
|
BlobPresignConfig,
|
|
@@ -97,6 +97,8 @@ export interface RealtimeNotifier {
|
|
|
97
97
|
|
|
98
98
|
export interface SyncServerConfig {
|
|
99
99
|
readonly schema: ServerSchema;
|
|
100
|
+
/** Current compiled schema followed by the supported previous versions, newest first. */
|
|
101
|
+
readonly schemaWindow?: readonly CompiledSchema[];
|
|
100
102
|
readonly storage: ServerStorage;
|
|
101
103
|
/**
|
|
102
104
|
* RFC 0007: the backfill checkpoints this host installs. Every readiness
|
package/src/handler.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
|
|
|
55
55
|
import { serveNotReadyError } from './readiness';
|
|
56
56
|
import type { CompiledSchema } from './schema';
|
|
57
57
|
import { compileSchema } from './schema';
|
|
58
|
+
import { schemaWindowOf, projectRowPayload } from './schema-window';
|
|
58
59
|
import { computeEffective, type ResolvedScopes } from './scopes';
|
|
59
60
|
import { StorageQueryError } from './storage-errors';
|
|
60
61
|
import {
|
|
@@ -343,7 +344,7 @@ async function planRequest(
|
|
|
343
344
|
const { resolved, leaseToEmit } = await resolveOnce(
|
|
344
345
|
ctx,
|
|
345
346
|
header.clientId,
|
|
346
|
-
schema,
|
|
347
|
+
compileSchema(ctx.schema),
|
|
347
348
|
);
|
|
348
349
|
|
|
349
350
|
const subscriptions: SubscriptionPlan[] = validated.map(({ frame }) => {
|
|
@@ -466,8 +467,10 @@ async function* streamResponse(
|
|
|
466
467
|
yield encodeResponseFrame(
|
|
467
468
|
{
|
|
468
469
|
type: 'RESP_HEADER',
|
|
469
|
-
requiredSchemaVersion: schema.
|
|
470
|
-
|
|
470
|
+
requiredSchemaVersion: schemaWindowOf(ctx.schema, ctx.schemaWindow).at(
|
|
471
|
+
-1,
|
|
472
|
+
)!.version,
|
|
473
|
+
latestSchemaVersion: ctx.schema.version,
|
|
471
474
|
...(plan.wireVersion >= 2
|
|
472
475
|
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
473
476
|
: {}),
|
|
@@ -480,7 +483,7 @@ async function* streamResponse(
|
|
|
480
483
|
yield encodeResponseFrame(
|
|
481
484
|
{
|
|
482
485
|
type: 'RESP_HEADER',
|
|
483
|
-
latestSchemaVersion: schema.version,
|
|
486
|
+
latestSchemaVersion: ctx.schema.version,
|
|
484
487
|
...(plan.wireVersion >= 2
|
|
485
488
|
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
486
489
|
: {}),
|
|
@@ -519,12 +522,35 @@ async function* streamResponse(
|
|
|
519
522
|
for (const push of plan.pushes) {
|
|
520
523
|
const processed = await processPushCommitWithTrace(
|
|
521
524
|
ctx,
|
|
522
|
-
schema,
|
|
525
|
+
compileSchema(ctx.schema),
|
|
523
526
|
plan.resolved,
|
|
524
527
|
plan.header.clientId,
|
|
525
528
|
push,
|
|
529
|
+
schema,
|
|
526
530
|
);
|
|
527
|
-
const
|
|
531
|
+
const frame = {
|
|
532
|
+
...processed.frame,
|
|
533
|
+
results: processed.frame.results.map((result) => {
|
|
534
|
+
if (result.status !== 'conflict') return result;
|
|
535
|
+
const operation = push.operations[result.opIndex]!;
|
|
536
|
+
const target = schema.tables.get(operation.table)!;
|
|
537
|
+
const current = compileSchema(ctx.schema).tables.get(
|
|
538
|
+
operation.table,
|
|
539
|
+
)!;
|
|
540
|
+
return {
|
|
541
|
+
...result,
|
|
542
|
+
serverRow: projectRowPayload(current, target, result.serverRow),
|
|
543
|
+
conflictColumns: result.conflictColumns
|
|
544
|
+
.slice(0, Math.ceil(target.columns.length / 8))
|
|
545
|
+
.map((byte, i) =>
|
|
546
|
+
i === Math.ceil(target.columns.length / 8) - 1 &&
|
|
547
|
+
target.columns.length % 8 !== 0
|
|
548
|
+
? byte & ((1 << (target.columns.length % 8)) - 1)
|
|
549
|
+
: byte,
|
|
550
|
+
),
|
|
551
|
+
};
|
|
552
|
+
}),
|
|
553
|
+
};
|
|
528
554
|
if (events !== undefined) {
|
|
529
555
|
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
530
556
|
}
|
|
@@ -630,6 +656,7 @@ async function* streamResponse(
|
|
|
630
656
|
id: s.frame.id,
|
|
631
657
|
table: s.frame.table,
|
|
632
658
|
scopes: s.frame.scopes,
|
|
659
|
+
schemaVersion: schema.version,
|
|
633
660
|
}))
|
|
634
661
|
: [...(previous?.subscriptions ?? [])];
|
|
635
662
|
const cursor =
|
|
@@ -754,7 +781,15 @@ async function createStreamCore(
|
|
|
754
781
|
const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
|
|
755
782
|
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
756
783
|
if (refusal !== undefined) throw serveNotReadyError(refusal);
|
|
757
|
-
const
|
|
784
|
+
const window = schemaWindowOf(ctx.schema, ctx.schemaWindow);
|
|
785
|
+
const header = request.frames[0];
|
|
786
|
+
const selected = window.find(
|
|
787
|
+
(candidate) =>
|
|
788
|
+
header?.type === 'REQ_HEADER' &&
|
|
789
|
+
candidate.version === header.schemaVersion,
|
|
790
|
+
);
|
|
791
|
+
const served = selected ?? schema;
|
|
792
|
+
const plan = await planRequest(request, ctx, served, registry);
|
|
758
793
|
const report: RequestReport = { outcome: 'ok' };
|
|
759
794
|
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
760
795
|
// builds the whole response (its data reads) before yielding the first
|
|
@@ -766,7 +801,7 @@ async function createStreamCore(
|
|
|
766
801
|
// key, so refusing the request is safe (proved in the serve-gate lane).
|
|
767
802
|
const verified = async function* (): AsyncGenerator<Uint8Array> {
|
|
768
803
|
const buffered: Uint8Array[] = [];
|
|
769
|
-
for await (const chunk of streamResponse(plan, ctx,
|
|
804
|
+
for await (const chunk of streamResponse(plan, ctx, served, report)) {
|
|
770
805
|
buffered.push(chunk);
|
|
771
806
|
}
|
|
772
807
|
const after = await ctx.storage.readServeGate(
|
package/src/index.ts
CHANGED
package/src/pull.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import {
|
|
6
6
|
type CommitChange,
|
|
7
|
-
decodeRow,
|
|
8
7
|
encodeRowsSegment,
|
|
9
8
|
type PullHeaderFrame,
|
|
10
9
|
type ResponseFrame,
|
|
@@ -26,6 +25,7 @@ import type { SegmentRecord, SegmentStore } from './segment-store';
|
|
|
26
25
|
import { issueSegmentUrl } from './signed-url';
|
|
27
26
|
import type { SqliteImageBuilder } from './sqlite-image';
|
|
28
27
|
import type { ServerStorage, StoredCommit, StoredRow } from './storage';
|
|
28
|
+
import { projectRowPayload, projectRowValues } from './schema-window';
|
|
29
29
|
|
|
30
30
|
// One artifact build per owning storage pair and complete immutable identity.
|
|
31
31
|
const imageBuilds = new WeakMap<
|
|
@@ -136,7 +136,12 @@ function parseBootstrapToken(
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
function commitFrame(
|
|
139
|
+
function commitFrame(
|
|
140
|
+
table: string,
|
|
141
|
+
commit: StoredCommit,
|
|
142
|
+
current: CompiledTable,
|
|
143
|
+
target: CompiledTable,
|
|
144
|
+
): ResponseFrame {
|
|
140
145
|
const changes: CommitChange[] = commit.changes.map((change) => ({
|
|
141
146
|
tableIndex: 0,
|
|
142
147
|
rowId: change.rowId,
|
|
@@ -145,7 +150,9 @@ function commitFrame(table: string, commit: StoredCommit): ResponseFrame {
|
|
|
145
150
|
? { rowVersion: change.rowVersion }
|
|
146
151
|
: {}),
|
|
147
152
|
scopes: change.scopes,
|
|
148
|
-
...(change.payload !== undefined
|
|
153
|
+
...(change.payload !== undefined
|
|
154
|
+
? { row: projectRowPayload(current, target, change.payload) }
|
|
155
|
+
: {}),
|
|
149
156
|
}));
|
|
150
157
|
return {
|
|
151
158
|
type: 'COMMIT',
|
|
@@ -318,7 +325,14 @@ async function* sqliteImageSegment(
|
|
|
318
325
|
scopeDigest: digest,
|
|
319
326
|
rowBatches: (async function* () {
|
|
320
327
|
rowCount += probe.length;
|
|
321
|
-
yield probe
|
|
328
|
+
yield probe.map((row) => ({
|
|
329
|
+
...row,
|
|
330
|
+
payload: projectRowPayload(
|
|
331
|
+
compileSchema(ctx.schema).tables.get(plan.table.name)!,
|
|
332
|
+
plan.table,
|
|
333
|
+
row.payload,
|
|
334
|
+
),
|
|
335
|
+
}));
|
|
322
336
|
let afterRowId = probe[probe.length - 1]!.rowId;
|
|
323
337
|
for (;;) {
|
|
324
338
|
const rows = await storage.scanRows(partition, {
|
|
@@ -328,7 +342,14 @@ async function* sqliteImageSegment(
|
|
|
328
342
|
limit: 5_000,
|
|
329
343
|
});
|
|
330
344
|
rowCount += rows.length;
|
|
331
|
-
yield rows
|
|
345
|
+
yield rows.map((row) => ({
|
|
346
|
+
...row,
|
|
347
|
+
payload: projectRowPayload(
|
|
348
|
+
compileSchema(ctx.schema).tables.get(plan.table.name)!,
|
|
349
|
+
plan.table,
|
|
350
|
+
row.payload,
|
|
351
|
+
),
|
|
352
|
+
}));
|
|
332
353
|
const last = rows[rows.length - 1];
|
|
333
354
|
if (rows.length < 5_000 || last === undefined) break;
|
|
334
355
|
afterRowId = last.rowId;
|
|
@@ -420,7 +441,11 @@ async function* bootstrapSegments(
|
|
|
420
441
|
// §5.2: every row record carries the row's current server_version.
|
|
421
442
|
const decoded = pageRows.map((row: StoredRow) => ({
|
|
422
443
|
serverVersion: row.serverVersion,
|
|
423
|
-
values:
|
|
444
|
+
values: projectRowValues(
|
|
445
|
+
compileSchema(ctx.schema).tables.get(plan.table.name)!,
|
|
446
|
+
plan.table,
|
|
447
|
+
row.payload,
|
|
448
|
+
),
|
|
424
449
|
}));
|
|
425
450
|
const bytes = encodeRowsSegment({
|
|
426
451
|
table: plan.table.name,
|
|
@@ -772,7 +797,12 @@ export async function* preparedSection(
|
|
|
772
797
|
) {
|
|
773
798
|
break;
|
|
774
799
|
}
|
|
775
|
-
yield commitFrame(
|
|
800
|
+
yield commitFrame(
|
|
801
|
+
sub.table,
|
|
802
|
+
commit,
|
|
803
|
+
compileSchema(ctx.schema).tables.get(sub.table)!,
|
|
804
|
+
plan.table,
|
|
805
|
+
);
|
|
776
806
|
delivered += commit.changes.length;
|
|
777
807
|
deliveredCommits += 1;
|
|
778
808
|
lastDeliveredSeq = commit.commitSeq;
|
package/src/push.ts
CHANGED
|
@@ -293,6 +293,7 @@ async function mergeCrdtColumns(
|
|
|
293
293
|
async function applyOperation(
|
|
294
294
|
tx: StorageTransaction,
|
|
295
295
|
schema: CompiledSchema,
|
|
296
|
+
wireSchema: CompiledSchema,
|
|
296
297
|
resolved: ResolvedScopes,
|
|
297
298
|
op: PushOperation,
|
|
298
299
|
opIndex: number,
|
|
@@ -303,7 +304,8 @@ async function applyOperation(
|
|
|
303
304
|
actorId: string,
|
|
304
305
|
): Promise<OperationOutcome> {
|
|
305
306
|
const table = schema.tables.get(op.table);
|
|
306
|
-
|
|
307
|
+
const wireTable = wireSchema.tables.get(op.table);
|
|
308
|
+
if (table === undefined || wireTable === undefined) {
|
|
307
309
|
return errorRecord(
|
|
308
310
|
opIndex,
|
|
309
311
|
'sync.unknown_table',
|
|
@@ -409,7 +411,13 @@ async function applyOperation(
|
|
|
409
411
|
}
|
|
410
412
|
let sparse: SparseRowValue[];
|
|
411
413
|
try {
|
|
412
|
-
sparse = decodeSparseRow(
|
|
414
|
+
sparse = decodeSparseRow(
|
|
415
|
+
wireTable.columns,
|
|
416
|
+
wireTable.primaryKeyIndex,
|
|
417
|
+
payload,
|
|
418
|
+
);
|
|
419
|
+
while (sparse.length < table.columns.length)
|
|
420
|
+
sparse.push(stored === undefined ? null : undefined);
|
|
413
421
|
} catch (error) {
|
|
414
422
|
return errorRecord(
|
|
415
423
|
opIndex,
|
|
@@ -1300,6 +1308,7 @@ export async function processPushCommitWithTrace(
|
|
|
1300
1308
|
resolved: ResolvedScopes,
|
|
1301
1309
|
clientId: string,
|
|
1302
1310
|
frame: PushCommitFrame,
|
|
1311
|
+
wireSchema: CompiledSchema = schema,
|
|
1303
1312
|
): Promise<ProcessedPushCommit> {
|
|
1304
1313
|
return processPushOperationsWithTrace(
|
|
1305
1314
|
ctx,
|
|
@@ -1308,6 +1317,7 @@ export async function processPushCommitWithTrace(
|
|
|
1308
1317
|
clientId,
|
|
1309
1318
|
frame.clientCommitId,
|
|
1310
1319
|
async () => frame.operations,
|
|
1320
|
+
wireSchema,
|
|
1311
1321
|
);
|
|
1312
1322
|
}
|
|
1313
1323
|
|
|
@@ -1325,6 +1335,7 @@ export async function processPushOperationsWithTrace(
|
|
|
1325
1335
|
buildOperations: (
|
|
1326
1336
|
tx: StorageTransaction,
|
|
1327
1337
|
) => Promise<readonly PushOperation[]>,
|
|
1338
|
+
wireSchema: CompiledSchema = schema,
|
|
1328
1339
|
): Promise<ProcessedPushCommit> {
|
|
1329
1340
|
const { storage, partition } = ctx;
|
|
1330
1341
|
let persisted: StoredPushResult | undefined;
|
|
@@ -1437,6 +1448,7 @@ export async function processPushOperationsWithTrace(
|
|
|
1437
1448
|
const outcome = await applyOperation(
|
|
1438
1449
|
tx,
|
|
1439
1450
|
schema,
|
|
1451
|
+
wireSchema,
|
|
1440
1452
|
resolved,
|
|
1441
1453
|
op,
|
|
1442
1454
|
opIndex,
|
package/src/readiness.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { SyncServerConfig } from './context';
|
|
10
10
|
import { type SyncError, syncError } from './errors';
|
|
11
11
|
import { type CompiledSchema, compileSchema } from './schema';
|
|
12
|
+
import { schemaWindowOf } from './schema-window';
|
|
12
13
|
import type { ServeGateRefusal } from './storage';
|
|
13
14
|
|
|
14
15
|
export const SYNC_SERVER_READINESS_ERROR_CODE =
|
|
@@ -47,11 +48,15 @@ export class SyncServerReadinessError extends Error {
|
|
|
47
48
|
* `cause` locally for the table/column or storage diagnostic.
|
|
48
49
|
*/
|
|
49
50
|
export async function ensureSyncServerReady(
|
|
50
|
-
config: Pick<
|
|
51
|
+
config: Pick<
|
|
52
|
+
SyncServerConfig,
|
|
53
|
+
'schema' | 'schemaWindow' | 'storage' | 'checkpoints'
|
|
54
|
+
>,
|
|
51
55
|
): Promise<void> {
|
|
52
56
|
let compiled: CompiledSchema;
|
|
53
57
|
try {
|
|
54
58
|
compiled = compileSchema(config.schema);
|
|
59
|
+
schemaWindowOf(config.schema, config.schemaWindow);
|
|
55
60
|
} catch (cause) {
|
|
56
61
|
throw new SyncServerReadinessError({
|
|
57
62
|
phase: 'schema_compile',
|
package/src/realtime.ts
CHANGED
|
@@ -48,7 +48,12 @@ import {
|
|
|
48
48
|
} from './errors';
|
|
49
49
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
50
50
|
import { createSyncResponseStream } from './handler';
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
type CompiledSchema,
|
|
53
|
+
type CompiledTable,
|
|
54
|
+
compileSchema,
|
|
55
|
+
} from './schema';
|
|
56
|
+
import { schemaWindowOf, projectRowPayload } from './schema-window';
|
|
52
57
|
import {
|
|
53
58
|
computeEffective,
|
|
54
59
|
matchesEffective,
|
|
@@ -99,6 +104,7 @@ export interface RealtimeConnectOptions {
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
interface Registration {
|
|
107
|
+
readonly codec: CompiledTable;
|
|
102
108
|
readonly id: string;
|
|
103
109
|
readonly table: string;
|
|
104
110
|
readonly effective: ScopeMap;
|
|
@@ -794,7 +800,9 @@ export class RealtimeSession {
|
|
|
794
800
|
? { rowVersion: change.rowVersion }
|
|
795
801
|
: {}),
|
|
796
802
|
scopes: change.scopes,
|
|
797
|
-
...(change.payload !== undefined
|
|
803
|
+
...(change.payload !== undefined
|
|
804
|
+
? { row: this.#hub.projectPayload(registration, change.payload) }
|
|
805
|
+
: {}),
|
|
798
806
|
}),
|
|
799
807
|
);
|
|
800
808
|
if (changes.length > 0) sections.push({ registration, changes });
|
|
@@ -894,6 +902,15 @@ export class RealtimeHub {
|
|
|
894
902
|
constructor(config: RealtimeHubConfig) {
|
|
895
903
|
this.#config = config;
|
|
896
904
|
this.#schema = compileSchema(config.schema);
|
|
905
|
+
schemaWindowOf(config.schema, config.schemaWindow);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
projectPayload(registration: Registration, payload: Uint8Array): Uint8Array {
|
|
909
|
+
return projectRowPayload(
|
|
910
|
+
this.#schema.tables.get(registration.table)!,
|
|
911
|
+
registration.codec,
|
|
912
|
+
payload,
|
|
913
|
+
);
|
|
897
914
|
}
|
|
898
915
|
|
|
899
916
|
get sessionCount(): number {
|
|
@@ -983,7 +1000,18 @@ export class RealtimeHub {
|
|
|
983
1000
|
}
|
|
984
1001
|
const registrations: Registration[] = [];
|
|
985
1002
|
for (const subscription of record?.subscriptions ?? []) {
|
|
986
|
-
const
|
|
1003
|
+
const window = schemaWindowOf(
|
|
1004
|
+
this.#config.schema,
|
|
1005
|
+
this.#config.schemaWindow,
|
|
1006
|
+
);
|
|
1007
|
+
const selected =
|
|
1008
|
+
subscription.schemaVersion === undefined &&
|
|
1009
|
+
this.#config.schemaWindow === undefined
|
|
1010
|
+
? this.#schema
|
|
1011
|
+
: window.find(
|
|
1012
|
+
(schema) => schema.version === subscription.schemaVersion,
|
|
1013
|
+
);
|
|
1014
|
+
const table = selected?.tables.get(subscription.table);
|
|
987
1015
|
if (table === undefined) continue;
|
|
988
1016
|
const keysValid = Object.keys(subscription.scopes).every((key) =>
|
|
989
1017
|
table.declaredVariables.has(key),
|
|
@@ -993,6 +1021,7 @@ export class RealtimeHub {
|
|
|
993
1021
|
if (outcome.status !== 'active') continue;
|
|
994
1022
|
registrations.push({
|
|
995
1023
|
id: subscription.id,
|
|
1024
|
+
codec: table,
|
|
996
1025
|
table: subscription.table,
|
|
997
1026
|
effective: outcome.effective,
|
|
998
1027
|
});
|
|
@@ -1022,6 +1051,9 @@ export class RealtimeHub {
|
|
|
1022
1051
|
partition: identity.partition,
|
|
1023
1052
|
actorId: identity.actorId,
|
|
1024
1053
|
schema: this.#config.schema,
|
|
1054
|
+
...(this.#config.schemaWindow !== undefined
|
|
1055
|
+
? { schemaWindow: this.#config.schemaWindow }
|
|
1056
|
+
: {}),
|
|
1025
1057
|
storage: this.#config.storage,
|
|
1026
1058
|
segments,
|
|
1027
1059
|
resolveScopes: this.#config.resolveScopes,
|
|
@@ -1139,7 +1171,19 @@ export class RealtimeHub {
|
|
|
1139
1171
|
clientId: options.clientId,
|
|
1140
1172
|
cursor,
|
|
1141
1173
|
latestCursor: latestSeq,
|
|
1142
|
-
requiresSync:
|
|
1174
|
+
requiresSync:
|
|
1175
|
+
record === undefined ||
|
|
1176
|
+
cursor < latestSeq ||
|
|
1177
|
+
(this.#config.schemaWindow !== undefined &&
|
|
1178
|
+
record.subscriptions.some(
|
|
1179
|
+
(subscription) =>
|
|
1180
|
+
!schemaWindowOf(
|
|
1181
|
+
this.#config.schema,
|
|
1182
|
+
this.#config.schemaWindow,
|
|
1183
|
+
).some(
|
|
1184
|
+
(schema) => schema.version === subscription.schemaVersion,
|
|
1185
|
+
),
|
|
1186
|
+
)),
|
|
1143
1187
|
timestamp: clock(),
|
|
1144
1188
|
},
|
|
1145
1189
|
}),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { decodeRow, encodeRow, type RowValue } from '@syncular/core';
|
|
2
|
+
import {
|
|
3
|
+
compileSchema,
|
|
4
|
+
type CompiledSchema,
|
|
5
|
+
type CompiledTable,
|
|
6
|
+
type ServerSchema,
|
|
7
|
+
} from './schema';
|
|
8
|
+
|
|
9
|
+
const validatedWindows = new WeakMap<
|
|
10
|
+
readonly CompiledSchema[],
|
|
11
|
+
CompiledSchema
|
|
12
|
+
>();
|
|
13
|
+
|
|
14
|
+
/** A host-reviewed newest-first window. Storage and validation always use current. */
|
|
15
|
+
export function schemaWindowOf(
|
|
16
|
+
current: ServerSchema,
|
|
17
|
+
configured?: readonly CompiledSchema[],
|
|
18
|
+
): readonly CompiledSchema[] {
|
|
19
|
+
const head = compileSchema(current);
|
|
20
|
+
if (configured === undefined) return [head];
|
|
21
|
+
if (validatedWindows.get(configured) === head) return configured;
|
|
22
|
+
if (configured.length === 0 || configured[0] !== head)
|
|
23
|
+
throw new Error('sync.schema_window.invalid_head');
|
|
24
|
+
let previous = head.version + 1;
|
|
25
|
+
for (const schema of configured) {
|
|
26
|
+
if (schema.version >= previous)
|
|
27
|
+
throw new Error('sync.schema_window.invalid_order');
|
|
28
|
+
previous = schema.version;
|
|
29
|
+
for (const table of schema.tables.values()) {
|
|
30
|
+
const next = head.tables.get(table.name);
|
|
31
|
+
if (
|
|
32
|
+
next === undefined ||
|
|
33
|
+
table.columns.length > next.columns.length ||
|
|
34
|
+
table.columns.some(
|
|
35
|
+
(column, i) =>
|
|
36
|
+
JSON.stringify(column) !== JSON.stringify(next.columns[i]),
|
|
37
|
+
) ||
|
|
38
|
+
next.columns
|
|
39
|
+
.slice(table.columns.length)
|
|
40
|
+
.some((column) => !column.nullable) ||
|
|
41
|
+
table.primaryKeyIndex !== next.primaryKeyIndex ||
|
|
42
|
+
JSON.stringify(table.scopePatterns) !==
|
|
43
|
+
JSON.stringify(next.scopePatterns) ||
|
|
44
|
+
JSON.stringify(table.references) !== JSON.stringify(next.references)
|
|
45
|
+
) {
|
|
46
|
+
throw new Error('sync.schema_window.incompatible_schema');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
validatedWindows.set(configured, head);
|
|
51
|
+
return configured;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Canonical stored bytes never leave the server under another version's codec. */
|
|
55
|
+
export function projectRowValues(
|
|
56
|
+
current: CompiledTable,
|
|
57
|
+
target: CompiledTable,
|
|
58
|
+
payload: Uint8Array,
|
|
59
|
+
): RowValue[] {
|
|
60
|
+
const values = decodeRow(current.columns, payload);
|
|
61
|
+
return values.slice(0, target.columns.length);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function projectRowPayload(
|
|
65
|
+
current: CompiledTable,
|
|
66
|
+
target: CompiledTable,
|
|
67
|
+
payload: Uint8Array,
|
|
68
|
+
): Uint8Array {
|
|
69
|
+
return current === target
|
|
70
|
+
? payload
|
|
71
|
+
: encodeRow(target.columns, projectRowValues(current, target, payload));
|
|
72
|
+
}
|
package/src/segment-download.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { SyncError, syncError } from './errors';
|
|
19
19
|
import { emitEvent } from './events';
|
|
20
20
|
import { compileSchema } from './schema';
|
|
21
|
+
import { schemaWindowOf } from './schema-window';
|
|
21
22
|
import { computeEffective, type ResolvedScopes, scopeDigest } from './scopes';
|
|
22
23
|
import {
|
|
23
24
|
bytesStream,
|
|
@@ -291,7 +292,10 @@ async function authorizePublication(
|
|
|
291
292
|
// earlier valid publication of the same bytes exists.
|
|
292
293
|
const schema = compileSchema(ctx.schema);
|
|
293
294
|
const candidates = live.filter((publication) => {
|
|
294
|
-
const
|
|
295
|
+
const served = schemaWindowOf(ctx.schema, ctx.schemaWindow).find(
|
|
296
|
+
(candidate) => candidate.version === publication.schemaVersion,
|
|
297
|
+
);
|
|
298
|
+
const table = served?.tables.get(publication.table);
|
|
295
299
|
return (
|
|
296
300
|
table !== undefined &&
|
|
297
301
|
Object.entries(requested).every(
|
package/src/storage.ts
CHANGED
|
@@ -288,6 +288,8 @@ export interface StoredPushResult {
|
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
export interface ClientSubscription {
|
|
291
|
+
/** Codec accepted by the last pull; absent on records written before schema windows. */
|
|
292
|
+
readonly schemaVersion?: number;
|
|
291
293
|
readonly id: string;
|
|
292
294
|
readonly table: string;
|
|
293
295
|
/** Requested scopes of the client's most recent pull (§8.1). */
|