@syncular/server 0.30.5 → 0.30.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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';
@@ -1,3 +1,4 @@
1
+ import { retainRollbackFailure } from './storage-errors.js';
1
2
  function queryable(db) {
2
3
  return {
3
4
  async query(text, params) {
@@ -32,7 +33,12 @@ export function pgliteExecutor(db) {
32
33
  return result;
33
34
  }
34
35
  catch (error) {
35
- await like.exec('ROLLBACK');
36
+ try {
37
+ await like.exec('ROLLBACK');
38
+ }
39
+ catch (rollbackError) {
40
+ retainRollbackFailure(error, rollbackError);
41
+ }
36
42
  throw error;
37
43
  }
38
44
  }
package/dist/pull.js CHANGED
@@ -2,11 +2,12 @@
2
2
  * Pull: incremental commit delivery, cursors, bootstrap segments
3
3
  * (SPEC.md §4, §5).
4
4
  */
5
- import { 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
@@ -1,3 +1,4 @@
1
+ import { retainRollbackFailure } from './storage-errors.js';
1
2
  /**
2
3
  * Push apply (SPEC.md §6) with §3.4 write-path authorization.
3
4
  *
@@ -161,9 +162,10 @@ async function mergeCrdtColumns(table, values, storedValues, present, opIndex, m
161
162
  }
162
163
  return { changed };
163
164
  }
164
- async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, actorId) {
165
+ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, actorId) {
165
166
  const table = schema.tables.get(op.table);
166
- if (table === undefined) {
167
+ const wireTable = wireSchema.tables.get(op.table);
168
+ if (table === undefined || wireTable === undefined) {
167
169
  return errorRecord(opIndex, 'sync.unknown_table', `table ${JSON.stringify(op.table)} is not handled by this server`);
168
170
  }
169
171
  if (!resolved.ok) {
@@ -237,7 +239,9 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
237
239
  }
238
240
  let sparse;
239
241
  try {
240
- sparse = decodeSparseRow(table.columns, table.primaryKeyIndex, payload);
242
+ sparse = decodeSparseRow(wireTable.columns, wireTable.primaryKeyIndex, payload);
243
+ while (sparse.length < table.columns.length)
244
+ sparse.push(stored === undefined ? null : undefined);
241
245
  }
242
246
  catch (error) {
243
247
  return errorRecord(opIndex, 'sync.invalid_request', `row payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`);
@@ -830,15 +834,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
830
834
  * rejected replays as `status: rejected`; this companion result preserves the
831
835
  * cache provenance needed by structured events and server helpers.
832
836
  */
833
- export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
834
- return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations);
837
+ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame, wireSchema = schema) {
838
+ return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations, wireSchema);
835
839
  }
836
840
  /**
837
841
  * Shared serialized apply path for SSP2 commits and authoritative commands.
838
842
  * The builder runs after the partition lock and idempotency re-check, so its
839
843
  * reads and the returned operations share the transaction that is committed.
840
844
  */
841
- export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations) {
845
+ export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema) {
842
846
  const { storage, partition } = ctx;
843
847
  let persisted;
844
848
  try {
@@ -936,7 +940,7 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
936
940
  const op = operations[opIndex];
937
941
  if (op === undefined)
938
942
  continue;
939
- const outcome = await applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, ctx.actorId);
943
+ const outcome = await applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, ctx.actorId);
940
944
  if (outcome.kind === 'terminate') {
941
945
  terminated = outcome.record;
942
946
  break;
@@ -1099,14 +1103,18 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1099
1103
  try {
1100
1104
  await tx.rollback();
1101
1105
  }
1102
- catch {
1103
- // Surface the finalization failure; a rollback failure would
1104
- // otherwise mask it.
1106
+ catch (rollbackError) {
1107
+ retainRollbackFailure(finalizationError, rollbackError);
1105
1108
  }
1106
1109
  throw finalizationError;
1107
1110
  }
1108
1111
  }
1109
- await tx.rollback();
1112
+ try {
1113
+ await tx.rollback();
1114
+ }
1115
+ catch (rollbackError) {
1116
+ retainRollbackFailure(error, rollbackError);
1117
+ }
1110
1118
  throw error;
1111
1119
  }
1112
1120
  }
@@ -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
  });
@@ -1,3 +1,4 @@
1
+ import { rollbackAfterError } from './storage-errors.js';
1
2
  /**
2
3
  * SQLite-image segment generation (SPEC.md §5.3): a complete SQLite
3
4
  * database file carrying one table's whole effective-scope snapshot at
@@ -79,7 +80,8 @@ export async function writeSqliteImage(db, input) {
79
80
  db.exec('COMMIT');
80
81
  }
81
82
  catch (error) {
82
- db.exec('ROLLBACK');
83
- throw error;
83
+ rollbackAfterError(() => {
84
+ db.exec('ROLLBACK');
85
+ }, error);
84
86
  }
85
87
  }
@@ -1,3 +1,4 @@
1
+ import { rollbackAfterError } from './storage-errors.js';
1
2
  /**
2
3
  * SQLite-backed segment store over the shared synchronous driver.
3
4
  */
@@ -137,8 +138,9 @@ export class SqliteSegmentStore {
137
138
  this.db.exec('COMMIT');
138
139
  }
139
140
  catch (error) {
140
- this.db.exec('ROLLBACK');
141
- throw error;
141
+ rollbackAfterError(() => {
142
+ this.db.exec('ROLLBACK');
143
+ }, error);
142
144
  }
143
145
  return record;
144
146
  }
@@ -1,3 +1,4 @@
1
+ import { retainRollbackFailure, rollbackAfterError } from './storage-errors.js';
1
2
  import { validateCommitPruneQuery } from './prune.js';
2
3
  import { StorageQueryError } from './storage-errors.js';
3
4
  import { serveGateRefusal } from './storage.js';
@@ -229,9 +230,8 @@ class SqliteTransaction {
229
230
  try {
230
231
  this.#storage.db.exec('ROLLBACK');
231
232
  }
232
- catch {
233
- // Surface the COMMIT failure; a rollback failure would otherwise
234
- // mask it.
233
+ catch (rollbackError) {
234
+ retainRollbackFailure(error, rollbackError);
235
235
  }
236
236
  throw error;
237
237
  }
@@ -433,8 +433,9 @@ END`);
433
433
  this.db.exec('COMMIT');
434
434
  }
435
435
  catch (error) {
436
- this.db.exec('ROLLBACK');
437
- throw error;
436
+ rollbackAfterError(() => {
437
+ this.db.exec('ROLLBACK');
438
+ }, error);
438
439
  }
439
440
  });
440
441
  }
@@ -486,8 +487,9 @@ END`);
486
487
  this.db.exec('COMMIT');
487
488
  }
488
489
  catch (error) {
489
- this.db.exec('ROLLBACK');
490
- throw error;
490
+ rollbackAfterError(() => {
491
+ this.db.exec('ROLLBACK');
492
+ }, error);
491
493
  }
492
494
  return {
493
495
  partition,
@@ -624,9 +626,10 @@ END`);
624
626
  return result;
625
627
  }
626
628
  catch (error) {
627
- if (open)
628
- this.db.exec('ROLLBACK');
629
- throw error;
629
+ rollbackAfterError(() => {
630
+ if (open)
631
+ this.db.exec('ROLLBACK');
632
+ }, error);
630
633
  }
631
634
  finally {
632
635
  release();
@@ -690,8 +693,9 @@ END`);
690
693
  };
691
694
  }
692
695
  catch (error) {
693
- this.db.exec('ROLLBACK');
694
- throw error;
696
+ rollbackAfterError(() => {
697
+ this.db.exec('ROLLBACK');
698
+ }, error);
695
699
  }
696
700
  });
697
701
  }
@@ -771,8 +775,9 @@ END`);
771
775
  return toStoredCheckpoint(row);
772
776
  }
773
777
  catch (error) {
774
- this.db.exec('ROLLBACK');
775
- throw error;
778
+ rollbackAfterError(() => {
779
+ this.db.exec('ROLLBACK');
780
+ }, error);
776
781
  }
777
782
  });
778
783
  }
@@ -810,8 +815,9 @@ END`);
810
815
  return Number(updated.changes) === 1 ? 'activated' : 'stale';
811
816
  }
812
817
  catch (error) {
813
- this.db.exec('ROLLBACK');
814
- throw error;
818
+ rollbackAfterError(() => {
819
+ this.db.exec('ROLLBACK');
820
+ }, error);
815
821
  }
816
822
  });
817
823
  }