@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 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 { frame } = processed;
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 plan = await planRequest(request, ctx, schema, registry);
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, schema, report)) {
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
@@ -52,3 +52,4 @@ export * from './sqlite-storage.js';
52
52
  export * from './storage.js';
53
53
  export { StorageQueryError, type StorageQueryErrorCode, } from './storage-errors.js';
54
54
  export * from './validate.js';
55
+ export { schemaWindowOf } from './schema-window.js';
package/dist/index.js CHANGED
@@ -56,3 +56,4 @@ export * from './sqlite-storage.js';
56
56
  export * from './storage.js';
57
57
  export { StorageQueryError, } from './storage-errors.js';
58
58
  export * from './validate.js';
59
+ export { schemaWindowOf } from './schema-window.js';
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 { decodeRow, encodeRowsSegment, } from '@syncular/core';
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 ? { row: change.payload } : {}),
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: decodeRow(plan.table.columns, row.payload),
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[]>): Promise<ProcessedPushCommit>;
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
- if (table === undefined) {
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(table.columns, table.primaryKeyIndex, payload);
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;
@@ -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({
@@ -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 ? { row: change.payload } : {}),
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 table = this.#schema.tables.get(subscription.table);
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 || cursor < latestSeq,
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
+ }
@@ -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 table = schema.tables.get(publication.table);
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.4",
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.4"
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.version,
470
- latestSchemaVersion: schema.version,
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 { frame } = processed;
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 plan = await planRequest(request, ctx, schema, registry);
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, schema, report)) {
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
@@ -93,3 +93,5 @@ export {
93
93
  type StorageQueryErrorCode,
94
94
  } from './storage-errors';
95
95
  export * from './validate';
96
+
97
+ export { schemaWindowOf } from './schema-window';
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(table: string, commit: StoredCommit): ResponseFrame {
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 ? { row: change.payload } : {}),
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: decodeRow(plan.table.columns, row.payload),
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(sub.table, commit);
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
- if (table === undefined) {
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(table.columns, table.primaryKeyIndex, payload);
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<SyncServerConfig, 'schema' | 'storage' | 'checkpoints'>,
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 { type CompiledSchema, compileSchema } from './schema';
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 ? { row: change.payload } : {}),
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 table = this.#schema.tables.get(subscription.table);
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: record === undefined || cursor < latestSeq,
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
+ }
@@ -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 table = schema.tables.get(publication.table);
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). */