@syncular/server 0.30.5 → 0.30.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/context.d.ts +3 -1
- package/dist/handler.js +34 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pg-executor-pglite.js +7 -1
- package/dist/pull.js +16 -7
- package/dist/push.d.ts +2 -2
- package/dist/push.js +19 -11
- package/dist/readiness.d.ts +1 -1
- package/dist/readiness.js +2 -0
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +23 -4
- package/dist/schema-window.d.ts +7 -0
- package/dist/schema-window.js +46 -0
- package/dist/segment-download.js +3 -1
- package/dist/sqlite-image.js +4 -2
- package/dist/sqlite-segment-store.js +4 -2
- package/dist/sqlite-storage.js +22 -16
- package/dist/storage-errors.d.ts +3 -0
- package/dist/storage-errors.js +15 -0
- package/dist/storage.d.ts +2 -0
- package/package.json +2 -2
- package/src/context.ts +3 -1
- package/src/handler.ts +43 -8
- package/src/index.ts +2 -0
- package/src/pg-executor-pglite.ts +6 -1
- package/src/pull.ts +37 -7
- package/src/push.ts +22 -6
- package/src/readiness.ts +6 -1
- package/src/realtime.ts +48 -4
- package/src/schema-window.ts +72 -0
- package/src/segment-download.ts +5 -1
- package/src/sqlite-image.ts +4 -2
- package/src/sqlite-segment-store.ts +4 -2
- package/src/sqlite-storage.ts +21 -15
- package/src/storage-errors.ts +19 -0
- package/src/storage.ts +2 -0
package/dist/storage-errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/storage-errors.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
470
|
-
|
|
470
|
+
requiredSchemaVersion: schemaWindowOf(ctx.schema, ctx.schemaWindow).at(
|
|
471
|
+
-1,
|
|
472
|
+
)!.version,
|
|
473
|
+
latestSchemaVersion: ctx.schema.version,
|
|
471
474
|
...(plan.wireVersion >= 2
|
|
472
475
|
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
473
476
|
: {}),
|
|
@@ -480,7 +483,7 @@ async function* streamResponse(
|
|
|
480
483
|
yield encodeResponseFrame(
|
|
481
484
|
{
|
|
482
485
|
type: 'RESP_HEADER',
|
|
483
|
-
latestSchemaVersion: schema.version,
|
|
486
|
+
latestSchemaVersion: ctx.schema.version,
|
|
484
487
|
...(plan.wireVersion >= 2
|
|
485
488
|
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
486
489
|
: {}),
|
|
@@ -519,12 +522,35 @@ async function* streamResponse(
|
|
|
519
522
|
for (const push of plan.pushes) {
|
|
520
523
|
const processed = await processPushCommitWithTrace(
|
|
521
524
|
ctx,
|
|
522
|
-
schema,
|
|
525
|
+
compileSchema(ctx.schema),
|
|
523
526
|
plan.resolved,
|
|
524
527
|
plan.header.clientId,
|
|
525
528
|
push,
|
|
529
|
+
schema,
|
|
526
530
|
);
|
|
527
|
-
const
|
|
531
|
+
const frame = {
|
|
532
|
+
...processed.frame,
|
|
533
|
+
results: processed.frame.results.map((result) => {
|
|
534
|
+
if (result.status !== 'conflict') return result;
|
|
535
|
+
const operation = push.operations[result.opIndex]!;
|
|
536
|
+
const target = schema.tables.get(operation.table)!;
|
|
537
|
+
const current = compileSchema(ctx.schema).tables.get(
|
|
538
|
+
operation.table,
|
|
539
|
+
)!;
|
|
540
|
+
return {
|
|
541
|
+
...result,
|
|
542
|
+
serverRow: projectRowPayload(current, target, result.serverRow),
|
|
543
|
+
conflictColumns: result.conflictColumns
|
|
544
|
+
.slice(0, Math.ceil(target.columns.length / 8))
|
|
545
|
+
.map((byte, i) =>
|
|
546
|
+
i === Math.ceil(target.columns.length / 8) - 1 &&
|
|
547
|
+
target.columns.length % 8 !== 0
|
|
548
|
+
? byte & ((1 << (target.columns.length % 8)) - 1)
|
|
549
|
+
: byte,
|
|
550
|
+
),
|
|
551
|
+
};
|
|
552
|
+
}),
|
|
553
|
+
};
|
|
528
554
|
if (events !== undefined) {
|
|
529
555
|
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
530
556
|
}
|
|
@@ -630,6 +656,7 @@ async function* streamResponse(
|
|
|
630
656
|
id: s.frame.id,
|
|
631
657
|
table: s.frame.table,
|
|
632
658
|
scopes: s.frame.scopes,
|
|
659
|
+
schemaVersion: schema.version,
|
|
633
660
|
}))
|
|
634
661
|
: [...(previous?.subscriptions ?? [])];
|
|
635
662
|
const cursor =
|
|
@@ -754,7 +781,15 @@ async function createStreamCore(
|
|
|
754
781
|
const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
|
|
755
782
|
const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
|
|
756
783
|
if (refusal !== undefined) throw serveNotReadyError(refusal);
|
|
757
|
-
const
|
|
784
|
+
const window = schemaWindowOf(ctx.schema, ctx.schemaWindow);
|
|
785
|
+
const header = request.frames[0];
|
|
786
|
+
const selected = window.find(
|
|
787
|
+
(candidate) =>
|
|
788
|
+
header?.type === 'REQ_HEADER' &&
|
|
789
|
+
candidate.version === header.schemaVersion,
|
|
790
|
+
);
|
|
791
|
+
const served = selected ?? schema;
|
|
792
|
+
const plan = await planRequest(request, ctx, served, registry);
|
|
758
793
|
const report: RequestReport = { outcome: 'ok' };
|
|
759
794
|
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
760
795
|
// builds the whole response (its data reads) before yielding the first
|
|
@@ -766,7 +801,7 @@ async function createStreamCore(
|
|
|
766
801
|
// key, so refusing the request is safe (proved in the serve-gate lane).
|
|
767
802
|
const verified = async function* (): AsyncGenerator<Uint8Array> {
|
|
768
803
|
const buffered: Uint8Array[] = [];
|
|
769
|
-
for await (const chunk of streamResponse(plan, ctx,
|
|
804
|
+
for await (const chunk of streamResponse(plan, ctx, served, report)) {
|
|
770
805
|
buffered.push(chunk);
|
|
771
806
|
}
|
|
772
807
|
const after = await ctx.storage.readServeGate(
|
package/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
139
|
+
function commitFrame(
|
|
140
|
+
table: string,
|
|
141
|
+
commit: StoredCommit,
|
|
142
|
+
current: CompiledTable,
|
|
143
|
+
target: CompiledTable,
|
|
144
|
+
): ResponseFrame {
|
|
140
145
|
const changes: CommitChange[] = commit.changes.map((change) => ({
|
|
141
146
|
tableIndex: 0,
|
|
142
147
|
rowId: change.rowId,
|
|
@@ -145,7 +150,9 @@ function commitFrame(table: string, commit: StoredCommit): ResponseFrame {
|
|
|
145
150
|
? { rowVersion: change.rowVersion }
|
|
146
151
|
: {}),
|
|
147
152
|
scopes: change.scopes,
|
|
148
|
-
...(change.payload !== undefined
|
|
153
|
+
...(change.payload !== undefined
|
|
154
|
+
? { row: projectRowPayload(current, target, change.payload) }
|
|
155
|
+
: {}),
|
|
149
156
|
}));
|
|
150
157
|
return {
|
|
151
158
|
type: 'COMMIT',
|
|
@@ -318,7 +325,14 @@ async function* sqliteImageSegment(
|
|
|
318
325
|
scopeDigest: digest,
|
|
319
326
|
rowBatches: (async function* () {
|
|
320
327
|
rowCount += probe.length;
|
|
321
|
-
yield probe
|
|
328
|
+
yield probe.map((row) => ({
|
|
329
|
+
...row,
|
|
330
|
+
payload: projectRowPayload(
|
|
331
|
+
compileSchema(ctx.schema).tables.get(plan.table.name)!,
|
|
332
|
+
plan.table,
|
|
333
|
+
row.payload,
|
|
334
|
+
),
|
|
335
|
+
}));
|
|
322
336
|
let afterRowId = probe[probe.length - 1]!.rowId;
|
|
323
337
|
for (;;) {
|
|
324
338
|
const rows = await storage.scanRows(partition, {
|
|
@@ -328,7 +342,14 @@ async function* sqliteImageSegment(
|
|
|
328
342
|
limit: 5_000,
|
|
329
343
|
});
|
|
330
344
|
rowCount += rows.length;
|
|
331
|
-
yield rows
|
|
345
|
+
yield rows.map((row) => ({
|
|
346
|
+
...row,
|
|
347
|
+
payload: projectRowPayload(
|
|
348
|
+
compileSchema(ctx.schema).tables.get(plan.table.name)!,
|
|
349
|
+
plan.table,
|
|
350
|
+
row.payload,
|
|
351
|
+
),
|
|
352
|
+
}));
|
|
332
353
|
const last = rows[rows.length - 1];
|
|
333
354
|
if (rows.length < 5_000 || last === undefined) break;
|
|
334
355
|
afterRowId = last.rowId;
|
|
@@ -420,7 +441,11 @@ async function* bootstrapSegments(
|
|
|
420
441
|
// §5.2: every row record carries the row's current server_version.
|
|
421
442
|
const decoded = pageRows.map((row: StoredRow) => ({
|
|
422
443
|
serverVersion: row.serverVersion,
|
|
423
|
-
values:
|
|
444
|
+
values: projectRowValues(
|
|
445
|
+
compileSchema(ctx.schema).tables.get(plan.table.name)!,
|
|
446
|
+
plan.table,
|
|
447
|
+
row.payload,
|
|
448
|
+
),
|
|
424
449
|
}));
|
|
425
450
|
const bytes = encodeRowsSegment({
|
|
426
451
|
table: plan.table.name,
|
|
@@ -772,7 +797,12 @@ export async function* preparedSection(
|
|
|
772
797
|
) {
|
|
773
798
|
break;
|
|
774
799
|
}
|
|
775
|
-
yield commitFrame(
|
|
800
|
+
yield commitFrame(
|
|
801
|
+
sub.table,
|
|
802
|
+
commit,
|
|
803
|
+
compileSchema(ctx.schema).tables.get(sub.table)!,
|
|
804
|
+
plan.table,
|
|
805
|
+
);
|
|
776
806
|
delivered += commit.changes.length;
|
|
777
807
|
deliveredCommits += 1;
|
|
778
808
|
lastDeliveredSeq = commit.commitSeq;
|
package/src/push.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
1654
|
-
// otherwise mask it.
|
|
1665
|
+
} catch (rollbackError) {
|
|
1666
|
+
retainRollbackFailure(finalizationError, rollbackError);
|
|
1655
1667
|
}
|
|
1656
1668
|
throw finalizationError;
|
|
1657
1669
|
}
|
|
1658
1670
|
}
|
|
1659
|
-
|
|
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<
|
|
51
|
+
config: Pick<
|
|
52
|
+
SyncServerConfig,
|
|
53
|
+
'schema' | 'schemaWindow' | 'storage' | 'checkpoints'
|
|
54
|
+
>,
|
|
51
55
|
): Promise<void> {
|
|
52
56
|
let compiled: CompiledSchema;
|
|
53
57
|
try {
|
|
54
58
|
compiled = compileSchema(config.schema);
|
|
59
|
+
schemaWindowOf(config.schema, config.schemaWindow);
|
|
55
60
|
} catch (cause) {
|
|
56
61
|
throw new SyncServerReadinessError({
|
|
57
62
|
phase: 'schema_compile',
|
package/src/realtime.ts
CHANGED
|
@@ -48,7 +48,12 @@ import {
|
|
|
48
48
|
} from './errors';
|
|
49
49
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
50
50
|
import { createSyncResponseStream } from './handler';
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
type CompiledSchema,
|
|
53
|
+
type CompiledTable,
|
|
54
|
+
compileSchema,
|
|
55
|
+
} from './schema';
|
|
56
|
+
import { schemaWindowOf, projectRowPayload } from './schema-window';
|
|
52
57
|
import {
|
|
53
58
|
computeEffective,
|
|
54
59
|
matchesEffective,
|
|
@@ -99,6 +104,7 @@ export interface RealtimeConnectOptions {
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
interface Registration {
|
|
107
|
+
readonly codec: CompiledTable;
|
|
102
108
|
readonly id: string;
|
|
103
109
|
readonly table: string;
|
|
104
110
|
readonly effective: ScopeMap;
|
|
@@ -794,7 +800,9 @@ export class RealtimeSession {
|
|
|
794
800
|
? { rowVersion: change.rowVersion }
|
|
795
801
|
: {}),
|
|
796
802
|
scopes: change.scopes,
|
|
797
|
-
...(change.payload !== undefined
|
|
803
|
+
...(change.payload !== undefined
|
|
804
|
+
? { row: this.#hub.projectPayload(registration, change.payload) }
|
|
805
|
+
: {}),
|
|
798
806
|
}),
|
|
799
807
|
);
|
|
800
808
|
if (changes.length > 0) sections.push({ registration, changes });
|
|
@@ -894,6 +902,15 @@ export class RealtimeHub {
|
|
|
894
902
|
constructor(config: RealtimeHubConfig) {
|
|
895
903
|
this.#config = config;
|
|
896
904
|
this.#schema = compileSchema(config.schema);
|
|
905
|
+
schemaWindowOf(config.schema, config.schemaWindow);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
projectPayload(registration: Registration, payload: Uint8Array): Uint8Array {
|
|
909
|
+
return projectRowPayload(
|
|
910
|
+
this.#schema.tables.get(registration.table)!,
|
|
911
|
+
registration.codec,
|
|
912
|
+
payload,
|
|
913
|
+
);
|
|
897
914
|
}
|
|
898
915
|
|
|
899
916
|
get sessionCount(): number {
|
|
@@ -983,7 +1000,18 @@ export class RealtimeHub {
|
|
|
983
1000
|
}
|
|
984
1001
|
const registrations: Registration[] = [];
|
|
985
1002
|
for (const subscription of record?.subscriptions ?? []) {
|
|
986
|
-
const
|
|
1003
|
+
const window = schemaWindowOf(
|
|
1004
|
+
this.#config.schema,
|
|
1005
|
+
this.#config.schemaWindow,
|
|
1006
|
+
);
|
|
1007
|
+
const selected =
|
|
1008
|
+
subscription.schemaVersion === undefined &&
|
|
1009
|
+
this.#config.schemaWindow === undefined
|
|
1010
|
+
? this.#schema
|
|
1011
|
+
: window.find(
|
|
1012
|
+
(schema) => schema.version === subscription.schemaVersion,
|
|
1013
|
+
);
|
|
1014
|
+
const table = selected?.tables.get(subscription.table);
|
|
987
1015
|
if (table === undefined) continue;
|
|
988
1016
|
const keysValid = Object.keys(subscription.scopes).every((key) =>
|
|
989
1017
|
table.declaredVariables.has(key),
|
|
@@ -993,6 +1021,7 @@ export class RealtimeHub {
|
|
|
993
1021
|
if (outcome.status !== 'active') continue;
|
|
994
1022
|
registrations.push({
|
|
995
1023
|
id: subscription.id,
|
|
1024
|
+
codec: table,
|
|
996
1025
|
table: subscription.table,
|
|
997
1026
|
effective: outcome.effective,
|
|
998
1027
|
});
|
|
@@ -1022,6 +1051,9 @@ export class RealtimeHub {
|
|
|
1022
1051
|
partition: identity.partition,
|
|
1023
1052
|
actorId: identity.actorId,
|
|
1024
1053
|
schema: this.#config.schema,
|
|
1054
|
+
...(this.#config.schemaWindow !== undefined
|
|
1055
|
+
? { schemaWindow: this.#config.schemaWindow }
|
|
1056
|
+
: {}),
|
|
1025
1057
|
storage: this.#config.storage,
|
|
1026
1058
|
segments,
|
|
1027
1059
|
resolveScopes: this.#config.resolveScopes,
|
|
@@ -1139,7 +1171,19 @@ export class RealtimeHub {
|
|
|
1139
1171
|
clientId: options.clientId,
|
|
1140
1172
|
cursor,
|
|
1141
1173
|
latestCursor: latestSeq,
|
|
1142
|
-
requiresSync:
|
|
1174
|
+
requiresSync:
|
|
1175
|
+
record === undefined ||
|
|
1176
|
+
cursor < latestSeq ||
|
|
1177
|
+
(this.#config.schemaWindow !== undefined &&
|
|
1178
|
+
record.subscriptions.some(
|
|
1179
|
+
(subscription) =>
|
|
1180
|
+
!schemaWindowOf(
|
|
1181
|
+
this.#config.schema,
|
|
1182
|
+
this.#config.schemaWindow,
|
|
1183
|
+
).some(
|
|
1184
|
+
(schema) => schema.version === subscription.schemaVersion,
|
|
1185
|
+
),
|
|
1186
|
+
)),
|
|
1143
1187
|
timestamp: clock(),
|
|
1144
1188
|
},
|
|
1145
1189
|
}),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { decodeRow, encodeRow, type RowValue } from '@syncular/core';
|
|
2
|
+
import {
|
|
3
|
+
compileSchema,
|
|
4
|
+
type CompiledSchema,
|
|
5
|
+
type CompiledTable,
|
|
6
|
+
type ServerSchema,
|
|
7
|
+
} from './schema';
|
|
8
|
+
|
|
9
|
+
const validatedWindows = new WeakMap<
|
|
10
|
+
readonly CompiledSchema[],
|
|
11
|
+
CompiledSchema
|
|
12
|
+
>();
|
|
13
|
+
|
|
14
|
+
/** A host-reviewed newest-first window. Storage and validation always use current. */
|
|
15
|
+
export function schemaWindowOf(
|
|
16
|
+
current: ServerSchema,
|
|
17
|
+
configured?: readonly CompiledSchema[],
|
|
18
|
+
): readonly CompiledSchema[] {
|
|
19
|
+
const head = compileSchema(current);
|
|
20
|
+
if (configured === undefined) return [head];
|
|
21
|
+
if (validatedWindows.get(configured) === head) return configured;
|
|
22
|
+
if (configured.length === 0 || configured[0] !== head)
|
|
23
|
+
throw new Error('sync.schema_window.invalid_head');
|
|
24
|
+
let previous = head.version + 1;
|
|
25
|
+
for (const schema of configured) {
|
|
26
|
+
if (schema.version >= previous)
|
|
27
|
+
throw new Error('sync.schema_window.invalid_order');
|
|
28
|
+
previous = schema.version;
|
|
29
|
+
for (const table of schema.tables.values()) {
|
|
30
|
+
const next = head.tables.get(table.name);
|
|
31
|
+
if (
|
|
32
|
+
next === undefined ||
|
|
33
|
+
table.columns.length > next.columns.length ||
|
|
34
|
+
table.columns.some(
|
|
35
|
+
(column, i) =>
|
|
36
|
+
JSON.stringify(column) !== JSON.stringify(next.columns[i]),
|
|
37
|
+
) ||
|
|
38
|
+
next.columns
|
|
39
|
+
.slice(table.columns.length)
|
|
40
|
+
.some((column) => !column.nullable) ||
|
|
41
|
+
table.primaryKeyIndex !== next.primaryKeyIndex ||
|
|
42
|
+
JSON.stringify(table.scopePatterns) !==
|
|
43
|
+
JSON.stringify(next.scopePatterns) ||
|
|
44
|
+
JSON.stringify(table.references) !== JSON.stringify(next.references)
|
|
45
|
+
) {
|
|
46
|
+
throw new Error('sync.schema_window.incompatible_schema');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
validatedWindows.set(configured, head);
|
|
51
|
+
return configured;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Canonical stored bytes never leave the server under another version's codec. */
|
|
55
|
+
export function projectRowValues(
|
|
56
|
+
current: CompiledTable,
|
|
57
|
+
target: CompiledTable,
|
|
58
|
+
payload: Uint8Array,
|
|
59
|
+
): RowValue[] {
|
|
60
|
+
const values = decodeRow(current.columns, payload);
|
|
61
|
+
return values.slice(0, target.columns.length);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function projectRowPayload(
|
|
65
|
+
current: CompiledTable,
|
|
66
|
+
target: CompiledTable,
|
|
67
|
+
payload: Uint8Array,
|
|
68
|
+
): Uint8Array {
|
|
69
|
+
return current === target
|
|
70
|
+
? payload
|
|
71
|
+
: encodeRow(target.columns, projectRowValues(current, target, payload));
|
|
72
|
+
}
|
package/src/segment-download.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { SyncError, syncError } from './errors';
|
|
19
19
|
import { emitEvent } from './events';
|
|
20
20
|
import { compileSchema } from './schema';
|
|
21
|
+
import { schemaWindowOf } from './schema-window';
|
|
21
22
|
import { computeEffective, type ResolvedScopes, scopeDigest } from './scopes';
|
|
22
23
|
import {
|
|
23
24
|
bytesStream,
|
|
@@ -291,7 +292,10 @@ async function authorizePublication(
|
|
|
291
292
|
// earlier valid publication of the same bytes exists.
|
|
292
293
|
const schema = compileSchema(ctx.schema);
|
|
293
294
|
const candidates = live.filter((publication) => {
|
|
294
|
-
const
|
|
295
|
+
const served = schemaWindowOf(ctx.schema, ctx.schemaWindow).find(
|
|
296
|
+
(candidate) => candidate.version === publication.schemaVersion,
|
|
297
|
+
);
|
|
298
|
+
const table = served?.tables.get(publication.table);
|
|
295
299
|
return (
|
|
296
300
|
table !== undefined &&
|
|
297
301
|
Object.entries(requested).every(
|
package/src/sqlite-image.ts
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
242
|
-
|
|
242
|
+
rollbackAfterError(() => {
|
|
243
|
+
this.db.exec('ROLLBACK');
|
|
244
|
+
}, error);
|
|
243
245
|
}
|
|
244
246
|
return record;
|
|
245
247
|
}
|