@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.
@@ -31,3 +31,6 @@ export declare function isPostgresConstraintError(error: unknown): boolean;
31
31
  * the original message is copied to the public StorageConstraintError.
32
32
  */
33
33
  export declare function isD1ConstraintError(error: unknown): boolean;
34
+ /** Preserve the operation failure when transaction cleanup also fails. */
35
+ export declare function retainRollbackFailure(error: unknown, rollbackError: unknown): void;
36
+ export declare function rollbackAfterError(run: () => void, error: unknown): never;
@@ -84,3 +84,18 @@ export function isD1ConstraintError(error) {
84
84
  return (typeof message === 'string' &&
85
85
  /^(?:D1(?:_EXEC)?_ERROR:\s*)?(?:UNIQUE|NOT NULL|CHECK|FOREIGN KEY) constraint failed\b/i.test(message));
86
86
  }
87
+ /** Preserve the operation failure when transaction cleanup also fails. */
88
+ export function retainRollbackFailure(error, rollbackError) {
89
+ if (error instanceof Error && !('rollbackError' in error)) {
90
+ Object.defineProperty(error, 'rollbackError', { value: rollbackError });
91
+ }
92
+ }
93
+ export function rollbackAfterError(run, error) {
94
+ try {
95
+ run();
96
+ }
97
+ catch (rollbackError) {
98
+ retainRollbackFailure(error, rollbackError);
99
+ }
100
+ throw error;
101
+ }
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.5",
3
+ "version": "0.30.7",
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.5"
71
+ "@syncular/core": "0.30.7"
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';
@@ -1,3 +1,4 @@
1
+ import { retainRollbackFailure } from './storage-errors';
1
2
  /**
2
3
  * A `PgExecutor` over @electric-sql/pglite (embedded WASM Postgres).
3
4
  *
@@ -64,7 +65,11 @@ export function pgliteExecutor(db: PGlite | PgliteLike): PgExecutor {
64
65
  await like.exec('COMMIT');
65
66
  return result;
66
67
  } catch (error) {
67
- await like.exec('ROLLBACK');
68
+ try {
69
+ await like.exec('ROLLBACK');
70
+ } catch (rollbackError) {
71
+ retainRollbackFailure(error, rollbackError);
72
+ }
68
73
  throw error;
69
74
  }
70
75
  } finally {
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
@@ -1,3 +1,4 @@
1
+ import { retainRollbackFailure } from './storage-errors';
1
2
  /**
2
3
  * Push apply (SPEC.md §6) with §3.4 write-path authorization.
3
4
  *
@@ -293,6 +294,7 @@ async function mergeCrdtColumns(
293
294
  async function applyOperation(
294
295
  tx: StorageTransaction,
295
296
  schema: CompiledSchema,
297
+ wireSchema: CompiledSchema,
296
298
  resolved: ResolvedScopes,
297
299
  op: PushOperation,
298
300
  opIndex: number,
@@ -303,7 +305,8 @@ async function applyOperation(
303
305
  actorId: string,
304
306
  ): Promise<OperationOutcome> {
305
307
  const table = schema.tables.get(op.table);
306
- if (table === undefined) {
308
+ const wireTable = wireSchema.tables.get(op.table);
309
+ if (table === undefined || wireTable === undefined) {
307
310
  return errorRecord(
308
311
  opIndex,
309
312
  'sync.unknown_table',
@@ -409,7 +412,13 @@ async function applyOperation(
409
412
  }
410
413
  let sparse: SparseRowValue[];
411
414
  try {
412
- sparse = decodeSparseRow(table.columns, table.primaryKeyIndex, payload);
415
+ sparse = decodeSparseRow(
416
+ wireTable.columns,
417
+ wireTable.primaryKeyIndex,
418
+ payload,
419
+ );
420
+ while (sparse.length < table.columns.length)
421
+ sparse.push(stored === undefined ? null : undefined);
413
422
  } catch (error) {
414
423
  return errorRecord(
415
424
  opIndex,
@@ -1300,6 +1309,7 @@ export async function processPushCommitWithTrace(
1300
1309
  resolved: ResolvedScopes,
1301
1310
  clientId: string,
1302
1311
  frame: PushCommitFrame,
1312
+ wireSchema: CompiledSchema = schema,
1303
1313
  ): Promise<ProcessedPushCommit> {
1304
1314
  return processPushOperationsWithTrace(
1305
1315
  ctx,
@@ -1308,6 +1318,7 @@ export async function processPushCommitWithTrace(
1308
1318
  clientId,
1309
1319
  frame.clientCommitId,
1310
1320
  async () => frame.operations,
1321
+ wireSchema,
1311
1322
  );
1312
1323
  }
1313
1324
 
@@ -1325,6 +1336,7 @@ export async function processPushOperationsWithTrace(
1325
1336
  buildOperations: (
1326
1337
  tx: StorageTransaction,
1327
1338
  ) => Promise<readonly PushOperation[]>,
1339
+ wireSchema: CompiledSchema = schema,
1328
1340
  ): Promise<ProcessedPushCommit> {
1329
1341
  const { storage, partition } = ctx;
1330
1342
  let persisted: StoredPushResult | undefined;
@@ -1437,6 +1449,7 @@ export async function processPushOperationsWithTrace(
1437
1449
  const outcome = await applyOperation(
1438
1450
  tx,
1439
1451
  schema,
1452
+ wireSchema,
1440
1453
  resolved,
1441
1454
  op,
1442
1455
  opIndex,
@@ -1649,14 +1662,17 @@ export async function processPushOperationsWithTrace(
1649
1662
  // a no-op when the finalization already committed.
1650
1663
  try {
1651
1664
  await tx.rollback();
1652
- } catch {
1653
- // Surface the finalization failure; a rollback failure would
1654
- // otherwise mask it.
1665
+ } catch (rollbackError) {
1666
+ retainRollbackFailure(finalizationError, rollbackError);
1655
1667
  }
1656
1668
  throw finalizationError;
1657
1669
  }
1658
1670
  }
1659
- await tx.rollback();
1671
+ try {
1672
+ await tx.rollback();
1673
+ } catch (rollbackError) {
1674
+ retainRollbackFailure(error, rollbackError);
1675
+ }
1660
1676
  throw error;
1661
1677
  }
1662
1678
  }
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(
@@ -1,3 +1,4 @@
1
+ import { rollbackAfterError } from './storage-errors';
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
@@ -124,7 +125,8 @@ export async function writeSqliteImage(
124
125
 
125
126
  db.exec('COMMIT');
126
127
  } catch (error) {
127
- db.exec('ROLLBACK');
128
- throw error;
128
+ rollbackAfterError(() => {
129
+ db.exec('ROLLBACK');
130
+ }, error);
129
131
  }
130
132
  }
@@ -1,3 +1,4 @@
1
+ import { rollbackAfterError } from './storage-errors';
1
2
  /**
2
3
  * SQLite-backed segment store over the shared synchronous driver.
3
4
  */
@@ -238,8 +239,9 @@ export class SqliteSegmentStore implements SegmentStore {
238
239
  }
239
240
  this.db.exec('COMMIT');
240
241
  } catch (error) {
241
- this.db.exec('ROLLBACK');
242
- throw error;
242
+ rollbackAfterError(() => {
243
+ this.db.exec('ROLLBACK');
244
+ }, error);
243
245
  }
244
246
  return record;
245
247
  }