@syncular/server 0.15.22 → 0.15.23
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 +15 -0
- package/dist/events.d.ts +10 -0
- package/dist/handler.js +21 -5
- package/dist/postgres-storage.js +12 -0
- package/dist/push.d.ts +13 -0
- package/dist/push.js +54 -14
- package/dist/seed.d.ts +27 -2
- package/dist/seed.js +71 -7
- package/dist/sqlite-dialect.js +12 -0
- package/dist/storage.d.ts +4 -0
- package/package.json +2 -2
- package/src/events.ts +10 -0
- package/src/handler.ts +21 -5
- package/src/postgres-storage.ts +14 -0
- package/src/push.ts +91 -17
- package/src/seed.ts +95 -10
- package/src/sqlite-dialect.ts +14 -0
- package/src/storage.ts +4 -0
package/README.md
CHANGED
|
@@ -173,6 +173,21 @@ Whole-commit validation checks a client-proposed commit; it does not grant
|
|
|
173
173
|
authority. Privileged operations such as connecting facilities still belong in
|
|
174
174
|
explicit server-authoritative commands.
|
|
175
175
|
|
|
176
|
+
## Seed idempotency and safe revisioning
|
|
177
|
+
|
|
178
|
+
`seedMutations` uses the real push path and a stable `clientId`/`commitId`.
|
|
179
|
+
Both applied and rejected outcomes are terminal for that key. A rejected call
|
|
180
|
+
throws `SeedMutationError`, whose structured `code`, `opIndex`, `replayed`,
|
|
181
|
+
`recordedAtMs`, and `cacheIdentity` fields distinguish a fresh policy failure
|
|
182
|
+
from replay of an older cached rejection.
|
|
183
|
+
|
|
184
|
+
After correcting a development seed definition, advance an explicit seed
|
|
185
|
+
revision (`catalog-v1` to `catalog-v2`) and rerun it; do not delete the database
|
|
186
|
+
or unrelated rows. This does not apply to application commands. After an
|
|
187
|
+
unknown command outcome, reuse the original idempotency key because changing it
|
|
188
|
+
can execute the operation twice. The full inspection and recovery recipe is in
|
|
189
|
+
the public [server guide](https://syncular.dev/guide-server/#seeding-data).
|
|
190
|
+
|
|
176
191
|
The task-oriented [concurrency and conflict-correction guide](https://syncular.dev/guide-concurrency-correction/)
|
|
177
192
|
shows version projection, aggregate rollback, corrected replacement commits,
|
|
178
193
|
explicit acknowledgement, and restart-safe recovery UI together.
|
package/dist/events.d.ts
CHANGED
|
@@ -59,11 +59,21 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
59
59
|
readonly type: 'push.rejected';
|
|
60
60
|
readonly code: string;
|
|
61
61
|
readonly opIndex: number;
|
|
62
|
+
/** True when the rejection was replayed from the idempotency cache. */
|
|
63
|
+
readonly replay: boolean;
|
|
64
|
+
/** Original host time for outcomes recorded by a metadata-aware server. */
|
|
65
|
+
readonly recordedAtMs?: number;
|
|
66
|
+
/** Privacy-safe identity of the stored outcome, when available. */
|
|
67
|
+
readonly cacheIdentity?: string;
|
|
62
68
|
}
|
|
63
69
|
/** A push commit terminated by a version conflict (§6.2). */
|
|
64
70
|
export interface PushConflictedEvent extends PushEventBase {
|
|
65
71
|
readonly type: 'push.conflicted';
|
|
66
72
|
readonly opIndex: number;
|
|
73
|
+
/** True when the conflict was replayed from the idempotency cache. */
|
|
74
|
+
readonly replay: boolean;
|
|
75
|
+
readonly recordedAtMs?: number;
|
|
76
|
+
readonly cacheIdentity?: string;
|
|
67
77
|
}
|
|
68
78
|
/** One emitted segment within a pull subscription section. */
|
|
69
79
|
export interface PullSegmentSummary {
|
package/dist/handler.js
CHANGED
|
@@ -15,7 +15,7 @@ import { SyncError, syncError } from './errors.js';
|
|
|
15
15
|
import { emitEvent, } from './events.js';
|
|
16
16
|
import { END_FRAME_BYTES, encodeResponseFrame, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
|
|
17
17
|
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
|
|
18
|
-
import {
|
|
18
|
+
import { processPushCommitWithTrace } from './push.js';
|
|
19
19
|
import { compileSchema } from './schema.js';
|
|
20
20
|
import { computeEffective } from './scopes.js';
|
|
21
21
|
function validateResolvedKeys(allowed, schema) {
|
|
@@ -199,7 +199,8 @@ function pushResultDetailsFrame(frame) {
|
|
|
199
199
|
entries,
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
|
-
function emitPushEvent(events, ctx, clientId, push,
|
|
202
|
+
function emitPushEvent(events, ctx, clientId, push, processed) {
|
|
203
|
+
const { frame } = processed;
|
|
203
204
|
const base = {
|
|
204
205
|
atMs: clockOf(ctx)(),
|
|
205
206
|
partition: ctx.partition,
|
|
@@ -213,7 +214,7 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
213
214
|
type: 'push.applied',
|
|
214
215
|
...base,
|
|
215
216
|
...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
|
|
216
|
-
replay:
|
|
217
|
+
replay: processed.replayed,
|
|
217
218
|
});
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
@@ -224,6 +225,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
224
225
|
type: 'push.conflicted',
|
|
225
226
|
...base,
|
|
226
227
|
opIndex: record.opIndex,
|
|
228
|
+
replay: processed.replayed,
|
|
229
|
+
...(processed.recordedAtMs !== undefined
|
|
230
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
231
|
+
: {}),
|
|
232
|
+
...(processed.cacheIdentity !== undefined
|
|
233
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
234
|
+
: {}),
|
|
227
235
|
});
|
|
228
236
|
return;
|
|
229
237
|
}
|
|
@@ -234,6 +242,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
234
242
|
? record.code
|
|
235
243
|
: 'sync.invalid_request',
|
|
236
244
|
opIndex: record?.opIndex ?? 0,
|
|
245
|
+
replay: processed.replayed,
|
|
246
|
+
...(processed.recordedAtMs !== undefined
|
|
247
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
248
|
+
: {}),
|
|
249
|
+
...(processed.cacheIdentity !== undefined
|
|
250
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
251
|
+
: {}),
|
|
237
252
|
});
|
|
238
253
|
}
|
|
239
254
|
async function* streamResponse(plan, ctx, schema, report) {
|
|
@@ -276,9 +291,10 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
276
291
|
try {
|
|
277
292
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
278
293
|
for (const push of plan.pushes) {
|
|
279
|
-
const
|
|
294
|
+
const processed = await processPushCommitWithTrace(ctx, schema, plan.resolved, plan.header.clientId, push);
|
|
295
|
+
const { frame } = processed;
|
|
280
296
|
if (events !== undefined) {
|
|
281
|
-
emitPushEvent(events, ctx, plan.header.clientId, push,
|
|
297
|
+
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
282
298
|
}
|
|
283
299
|
yield encodeResponseFrame(frame);
|
|
284
300
|
const details = pushResultDetailsFrame(frame);
|
package/dist/postgres-storage.js
CHANGED
|
@@ -91,6 +91,12 @@ function serializePushResult(result) {
|
|
|
91
91
|
return {
|
|
92
92
|
status: result.status,
|
|
93
93
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
94
|
+
...(result.recordedAtMs !== undefined
|
|
95
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
96
|
+
: {}),
|
|
97
|
+
...(result.cacheIdentity !== undefined
|
|
98
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
99
|
+
: {}),
|
|
94
100
|
results: result.results.map((record) => {
|
|
95
101
|
if (record.status === 'conflict') {
|
|
96
102
|
return {
|
|
@@ -144,6 +150,12 @@ function deserializePushResult(value) {
|
|
|
144
150
|
return {
|
|
145
151
|
status: parsed.status,
|
|
146
152
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
153
|
+
...(parsed.recordedAtMs !== undefined
|
|
154
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
155
|
+
: {}),
|
|
156
|
+
...(parsed.cacheIdentity !== undefined
|
|
157
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
158
|
+
: {}),
|
|
147
159
|
results,
|
|
148
160
|
};
|
|
149
161
|
}
|
package/dist/push.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ import type { SyncRequestContext } from './context.js';
|
|
|
22
22
|
import type { CompiledSchema } from './schema.js';
|
|
23
23
|
import type { ResolvedScopes } from './scopes.js';
|
|
24
24
|
import type { StoredCommit } from './storage.js';
|
|
25
|
+
export interface ProcessedPushCommit {
|
|
26
|
+
readonly frame: PushResultFrame;
|
|
27
|
+
/** True when this request observed an already-recorded idempotency outcome. */
|
|
28
|
+
readonly replayed: boolean;
|
|
29
|
+
readonly recordedAtMs?: number;
|
|
30
|
+
readonly cacheIdentity?: string;
|
|
31
|
+
}
|
|
25
32
|
export interface AppliedCommitEvent {
|
|
26
33
|
readonly commit: StoredCommit;
|
|
27
34
|
}
|
|
@@ -30,3 +37,9 @@ export interface AppliedCommitEvent {
|
|
|
30
37
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
31
38
|
*/
|
|
32
39
|
export declare function processPushCommit(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<PushResultFrame>;
|
|
40
|
+
/**
|
|
41
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
42
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
43
|
+
* cache provenance needed by structured events and server helpers.
|
|
44
|
+
*/
|
|
45
|
+
export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<ProcessedPushCommit>;
|
package/dist/push.js
CHANGED
|
@@ -466,6 +466,25 @@ function resultFrame(clientCommitId, stored, replay) {
|
|
|
466
466
|
results: [...stored.results],
|
|
467
467
|
};
|
|
468
468
|
}
|
|
469
|
+
function processedPushCommit(clientCommitId, stored, replayed) {
|
|
470
|
+
return {
|
|
471
|
+
frame: resultFrame(clientCommitId, stored, replayed),
|
|
472
|
+
replayed,
|
|
473
|
+
...(stored.recordedAtMs !== undefined
|
|
474
|
+
? { recordedAtMs: stored.recordedAtMs }
|
|
475
|
+
: {}),
|
|
476
|
+
...(stored.cacheIdentity !== undefined
|
|
477
|
+
? { cacheIdentity: stored.cacheIdentity }
|
|
478
|
+
: {}),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function newStoredPushResult(recordedAtMs, result) {
|
|
482
|
+
return {
|
|
483
|
+
...result,
|
|
484
|
+
recordedAtMs,
|
|
485
|
+
cacheIdentity: crypto.randomUUID(),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
469
488
|
function idempotencyCacheMissFrame(clientCommitId, error) {
|
|
470
489
|
return {
|
|
471
490
|
type: 'PUSH_RESULT',
|
|
@@ -496,13 +515,24 @@ async function persistRejectedPushResult(storage, partition, clientId, clientCom
|
|
|
496
515
|
if (canonical === undefined) {
|
|
497
516
|
throw new Error('push rejection finalization did not persist an outcome');
|
|
498
517
|
}
|
|
499
|
-
return
|
|
518
|
+
return {
|
|
519
|
+
stored: canonical,
|
|
520
|
+
replayed: canonical.cacheIdentity !== stored.cacheIdentity,
|
|
521
|
+
};
|
|
500
522
|
}
|
|
501
523
|
/**
|
|
502
524
|
* Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
|
|
503
525
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
504
526
|
*/
|
|
505
527
|
export async function processPushCommit(ctx, schema, resolved, clientId, frame) {
|
|
528
|
+
return (await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)).frame;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
532
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
533
|
+
* cache provenance needed by structured events and server helpers.
|
|
534
|
+
*/
|
|
535
|
+
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
|
|
506
536
|
const { storage, partition } = ctx;
|
|
507
537
|
let persisted;
|
|
508
538
|
try {
|
|
@@ -513,12 +543,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
513
543
|
error.code === 'sync.idempotency_cache_miss') {
|
|
514
544
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
515
545
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
516
|
-
return
|
|
546
|
+
return {
|
|
547
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
548
|
+
replayed: false,
|
|
549
|
+
};
|
|
517
550
|
}
|
|
518
551
|
throw error;
|
|
519
552
|
}
|
|
520
553
|
if (persisted !== undefined) {
|
|
521
|
-
return
|
|
554
|
+
return processedPushCommit(frame.clientCommitId, persisted, true);
|
|
522
555
|
}
|
|
523
556
|
const createdAtMs = clockOf(ctx)();
|
|
524
557
|
const blobCtx = { store: ctx.blobs, partition };
|
|
@@ -541,14 +574,17 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
541
574
|
const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
|
|
542
575
|
if (serializedPersisted !== undefined) {
|
|
543
576
|
await tx.rollback();
|
|
544
|
-
return
|
|
577
|
+
return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
|
|
545
578
|
}
|
|
546
579
|
}
|
|
547
580
|
catch (error) {
|
|
548
581
|
if (error instanceof SyncError &&
|
|
549
582
|
error.code === 'sync.idempotency_cache_miss') {
|
|
550
583
|
await tx.rollback();
|
|
551
|
-
return
|
|
584
|
+
return {
|
|
585
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
586
|
+
replayed: false,
|
|
587
|
+
};
|
|
552
588
|
}
|
|
553
589
|
throw error;
|
|
554
590
|
}
|
|
@@ -580,10 +616,10 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
580
616
|
if (terminated !== undefined) {
|
|
581
617
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
582
618
|
// every write of the commit rolls back.
|
|
583
|
-
const stored = {
|
|
619
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
584
620
|
status: 'rejected',
|
|
585
621
|
results: [terminated],
|
|
586
|
-
};
|
|
622
|
+
});
|
|
587
623
|
if (commitValidator !== undefined) {
|
|
588
624
|
// Discard candidate rows and persist the rejection while retaining the
|
|
589
625
|
// same partition lock. This closes the duplicate-request race between
|
|
@@ -596,9 +632,9 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
596
632
|
else {
|
|
597
633
|
await tx.rollback();
|
|
598
634
|
const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
|
|
599
|
-
return
|
|
635
|
+
return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
|
|
600
636
|
}
|
|
601
|
-
return
|
|
637
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
602
638
|
}
|
|
603
639
|
const commitSeq = await tx.appendCommit({
|
|
604
640
|
clientId,
|
|
@@ -607,7 +643,11 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
607
643
|
createdAtMs,
|
|
608
644
|
changes,
|
|
609
645
|
});
|
|
610
|
-
const stored =
|
|
646
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
647
|
+
status: 'applied',
|
|
648
|
+
commitSeq,
|
|
649
|
+
results,
|
|
650
|
+
});
|
|
611
651
|
await tx.putPushResult(clientId, frame.clientCommitId, stored);
|
|
612
652
|
await tx.commit();
|
|
613
653
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
@@ -618,12 +658,12 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
618
658
|
changes,
|
|
619
659
|
});
|
|
620
660
|
}
|
|
621
|
-
return
|
|
661
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
622
662
|
}
|
|
623
663
|
catch (error) {
|
|
624
664
|
await tx.rollback();
|
|
625
665
|
if (error instanceof StorageConstraintError) {
|
|
626
|
-
const stored = {
|
|
666
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
627
667
|
status: 'rejected',
|
|
628
668
|
results: [
|
|
629
669
|
{
|
|
@@ -634,9 +674,9 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
634
674
|
retryable: false,
|
|
635
675
|
},
|
|
636
676
|
],
|
|
637
|
-
};
|
|
677
|
+
});
|
|
638
678
|
const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
|
|
639
|
-
return
|
|
679
|
+
return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
|
|
640
680
|
}
|
|
641
681
|
throw error;
|
|
642
682
|
}
|
package/dist/seed.d.ts
CHANGED
|
@@ -26,9 +26,34 @@ export interface SeedTarget {
|
|
|
26
26
|
/** The client commit id (default `'seed-commit-1'`). */
|
|
27
27
|
readonly commitId?: string;
|
|
28
28
|
}
|
|
29
|
+
export interface SeedMutationErrorOptions {
|
|
30
|
+
readonly clientId: string;
|
|
31
|
+
readonly clientCommitId: string;
|
|
32
|
+
readonly opIndex: number;
|
|
33
|
+
readonly code: string;
|
|
34
|
+
readonly replayed: boolean;
|
|
35
|
+
readonly retryable: boolean;
|
|
36
|
+
readonly message: string;
|
|
37
|
+
readonly recordedAtMs?: number;
|
|
38
|
+
readonly cacheIdentity?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
41
|
+
export declare class SeedMutationError extends Error {
|
|
42
|
+
readonly name = "SeedMutationError";
|
|
43
|
+
readonly clientId: string;
|
|
44
|
+
readonly clientCommitId: string;
|
|
45
|
+
readonly opIndex: number;
|
|
46
|
+
/** Exact protocol or host-validator rejection code. */
|
|
47
|
+
readonly code: string;
|
|
48
|
+
readonly replayed: boolean;
|
|
49
|
+
readonly retryable: boolean;
|
|
50
|
+
readonly recordedAtMs?: number;
|
|
51
|
+
readonly cacheIdentity?: string;
|
|
52
|
+
constructor(options: SeedMutationErrorOptions);
|
|
53
|
+
}
|
|
29
54
|
/**
|
|
30
55
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
31
|
-
* `
|
|
32
|
-
* seed fails loud
|
|
56
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
57
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
33
58
|
*/
|
|
34
59
|
export declare function seedMutations(config: SyncServerConfig, target: SeedTarget, mutations: readonly SeedMutation[]): Promise<void>;
|
package/dist/seed.js
CHANGED
|
@@ -11,7 +11,36 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { decodeMessage, encodeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
13
13
|
import { SyncError } from './errors.js';
|
|
14
|
+
import { composeEvents } from './events-ring.js';
|
|
14
15
|
import { handleSyncRequest } from './handler.js';
|
|
16
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
17
|
+
export class SeedMutationError extends Error {
|
|
18
|
+
name = 'SeedMutationError';
|
|
19
|
+
clientId;
|
|
20
|
+
clientCommitId;
|
|
21
|
+
opIndex;
|
|
22
|
+
/** Exact protocol or host-validator rejection code. */
|
|
23
|
+
code;
|
|
24
|
+
replayed;
|
|
25
|
+
retryable;
|
|
26
|
+
recordedAtMs;
|
|
27
|
+
cacheIdentity;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
super(options.message);
|
|
30
|
+
this.clientId = options.clientId;
|
|
31
|
+
this.clientCommitId = options.clientCommitId;
|
|
32
|
+
this.opIndex = options.opIndex;
|
|
33
|
+
this.code = options.code;
|
|
34
|
+
this.replayed = options.replayed;
|
|
35
|
+
this.retryable = options.retryable;
|
|
36
|
+
if (options.recordedAtMs !== undefined) {
|
|
37
|
+
this.recordedAtMs = options.recordedAtMs;
|
|
38
|
+
}
|
|
39
|
+
if (options.cacheIdentity !== undefined) {
|
|
40
|
+
this.cacheIdentity = options.cacheIdentity;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
15
44
|
const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
|
|
16
45
|
/** Pinned §12 schema alias used by generated row types and every client host. */
|
|
17
46
|
function snakeToCamel(name) {
|
|
@@ -35,8 +64,8 @@ function snakeToCamel(name) {
|
|
|
35
64
|
}
|
|
36
65
|
/**
|
|
37
66
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
38
|
-
* `
|
|
39
|
-
* seed fails loud
|
|
67
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
68
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
40
69
|
*/
|
|
41
70
|
export async function seedMutations(config, target, mutations) {
|
|
42
71
|
const clientId = target.clientId ?? 'seed';
|
|
@@ -88,11 +117,28 @@ export async function seedMutations(config, target, mutations) {
|
|
|
88
117
|
accept: 0b0011,
|
|
89
118
|
},
|
|
90
119
|
];
|
|
120
|
+
let terminalEvent;
|
|
121
|
+
const capture = {
|
|
122
|
+
emit(event) {
|
|
123
|
+
if ((event.type === 'push.rejected' || event.type === 'push.conflicted') &&
|
|
124
|
+
event.clientId === clientId &&
|
|
125
|
+
event.clientCommitId === clientCommitId) {
|
|
126
|
+
terminalEvent = event;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
91
130
|
const response = await handleSyncRequest(encodeMessage({
|
|
92
131
|
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
93
132
|
msgKind: 'request',
|
|
94
133
|
frames,
|
|
95
|
-
}), {
|
|
134
|
+
}), {
|
|
135
|
+
...config,
|
|
136
|
+
partition: target.partition,
|
|
137
|
+
actorId: target.actorId,
|
|
138
|
+
events: config.events === undefined
|
|
139
|
+
? capture
|
|
140
|
+
: composeEvents(config.events, capture),
|
|
141
|
+
});
|
|
96
142
|
// Fail loud: surface the first rejected/failed operation.
|
|
97
143
|
const message = decodeMessage(response);
|
|
98
144
|
const result = message.frames.find((frame) => frame.type === 'PUSH_RESULT' && frame.clientCommitId === clientCommitId);
|
|
@@ -101,9 +147,27 @@ export async function seedMutations(config, target, mutations) {
|
|
|
101
147
|
}
|
|
102
148
|
if (result.status === 'rejected') {
|
|
103
149
|
const failed = result.results.find((r) => r.status !== 'applied');
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
const code = failed?.code ?? 'sync.invalid_request';
|
|
151
|
+
const opIndex = failed?.opIndex ?? 0;
|
|
152
|
+
const retryable = failed?.status === 'error' ? failed.retryable : false;
|
|
153
|
+
const detail = failed === undefined
|
|
154
|
+
? ''
|
|
155
|
+
: ` (op ${opIndex}: ${code} — ${failed.message})`;
|
|
156
|
+
const replayed = terminalEvent?.replay ?? false;
|
|
157
|
+
throw new SeedMutationError({
|
|
158
|
+
clientId,
|
|
159
|
+
clientCommitId,
|
|
160
|
+
opIndex,
|
|
161
|
+
code,
|
|
162
|
+
replayed,
|
|
163
|
+
retryable,
|
|
164
|
+
message: `seedMutations: the seed commit was rejected${replayed ? ' (cached replay)' : ''}${detail}`,
|
|
165
|
+
...(terminalEvent?.recordedAtMs !== undefined
|
|
166
|
+
? { recordedAtMs: terminalEvent.recordedAtMs }
|
|
167
|
+
: {}),
|
|
168
|
+
...(terminalEvent?.cacheIdentity !== undefined
|
|
169
|
+
? { cacheIdentity: terminalEvent.cacheIdentity }
|
|
170
|
+
: {}),
|
|
171
|
+
});
|
|
108
172
|
}
|
|
109
173
|
}
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -93,6 +93,12 @@ export function serializePushResult(result) {
|
|
|
93
93
|
return JSON.stringify({
|
|
94
94
|
status: result.status,
|
|
95
95
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
96
|
+
...(result.recordedAtMs !== undefined
|
|
97
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
98
|
+
: {}),
|
|
99
|
+
...(result.cacheIdentity !== undefined
|
|
100
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
101
|
+
: {}),
|
|
96
102
|
results: result.results.map((record) => {
|
|
97
103
|
if (record.status === 'conflict') {
|
|
98
104
|
return {
|
|
@@ -146,6 +152,12 @@ export function deserializePushResult(text) {
|
|
|
146
152
|
return {
|
|
147
153
|
status: parsed.status,
|
|
148
154
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
155
|
+
...(parsed.recordedAtMs !== undefined
|
|
156
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
157
|
+
: {}),
|
|
158
|
+
...(parsed.cacheIdentity !== undefined
|
|
159
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
160
|
+
: {}),
|
|
149
161
|
results,
|
|
150
162
|
};
|
|
151
163
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -64,6 +64,10 @@ export interface StoredPushResult {
|
|
|
64
64
|
readonly status: 'applied' | 'rejected';
|
|
65
65
|
/** Present iff `status` is `applied`. */
|
|
66
66
|
readonly commitSeq?: number;
|
|
67
|
+
/** Host clock when this terminal idempotency outcome was first recorded. */
|
|
68
|
+
readonly recordedAtMs?: number;
|
|
69
|
+
/** Privacy-safe identity used to distinguish this stored outcome from a race. */
|
|
70
|
+
readonly cacheIdentity?: string;
|
|
67
71
|
readonly results: readonly PushOperationResult[];
|
|
68
72
|
}
|
|
69
73
|
export interface ClientSubscription {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.23",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"!dist/**/*.test.d.ts"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@syncular/core": "0.15.
|
|
56
|
+
"@syncular/core": "0.15.23"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/events.ts
CHANGED
|
@@ -63,12 +63,22 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
63
63
|
readonly type: 'push.rejected';
|
|
64
64
|
readonly code: string;
|
|
65
65
|
readonly opIndex: number;
|
|
66
|
+
/** True when the rejection was replayed from the idempotency cache. */
|
|
67
|
+
readonly replay: boolean;
|
|
68
|
+
/** Original host time for outcomes recorded by a metadata-aware server. */
|
|
69
|
+
readonly recordedAtMs?: number;
|
|
70
|
+
/** Privacy-safe identity of the stored outcome, when available. */
|
|
71
|
+
readonly cacheIdentity?: string;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/** A push commit terminated by a version conflict (§6.2). */
|
|
69
75
|
export interface PushConflictedEvent extends PushEventBase {
|
|
70
76
|
readonly type: 'push.conflicted';
|
|
71
77
|
readonly opIndex: number;
|
|
78
|
+
/** True when the conflict was replayed from the idempotency cache. */
|
|
79
|
+
readonly replay: boolean;
|
|
80
|
+
readonly recordedAtMs?: number;
|
|
81
|
+
readonly cacheIdentity?: string;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
/** One emitted segment within a pull subscription section. */
|
package/src/handler.ts
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
type SubscriptionPlan,
|
|
45
45
|
subscriptionSection,
|
|
46
46
|
} from './pull';
|
|
47
|
-
import {
|
|
47
|
+
import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
|
|
48
48
|
import type { CompiledSchema } from './schema';
|
|
49
49
|
import { compileSchema } from './schema';
|
|
50
50
|
import { computeEffective, type ResolvedScopes } from './scopes';
|
|
@@ -339,8 +339,9 @@ function emitPushEvent(
|
|
|
339
339
|
ctx: SyncRequestContext,
|
|
340
340
|
clientId: string,
|
|
341
341
|
push: PushCommitFrame,
|
|
342
|
-
|
|
342
|
+
processed: ProcessedPushCommit,
|
|
343
343
|
): void {
|
|
344
|
+
const { frame } = processed;
|
|
344
345
|
const base = {
|
|
345
346
|
atMs: clockOf(ctx)(),
|
|
346
347
|
partition: ctx.partition,
|
|
@@ -354,7 +355,7 @@ function emitPushEvent(
|
|
|
354
355
|
type: 'push.applied',
|
|
355
356
|
...base,
|
|
356
357
|
...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
|
|
357
|
-
replay:
|
|
358
|
+
replay: processed.replayed,
|
|
358
359
|
});
|
|
359
360
|
return;
|
|
360
361
|
}
|
|
@@ -365,6 +366,13 @@ function emitPushEvent(
|
|
|
365
366
|
type: 'push.conflicted',
|
|
366
367
|
...base,
|
|
367
368
|
opIndex: record.opIndex,
|
|
369
|
+
replay: processed.replayed,
|
|
370
|
+
...(processed.recordedAtMs !== undefined
|
|
371
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
372
|
+
: {}),
|
|
373
|
+
...(processed.cacheIdentity !== undefined
|
|
374
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
375
|
+
: {}),
|
|
368
376
|
});
|
|
369
377
|
return;
|
|
370
378
|
}
|
|
@@ -376,6 +384,13 @@ function emitPushEvent(
|
|
|
376
384
|
? record.code
|
|
377
385
|
: 'sync.invalid_request',
|
|
378
386
|
opIndex: record?.opIndex ?? 0,
|
|
387
|
+
replay: processed.replayed,
|
|
388
|
+
...(processed.recordedAtMs !== undefined
|
|
389
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
390
|
+
: {}),
|
|
391
|
+
...(processed.cacheIdentity !== undefined
|
|
392
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
393
|
+
: {}),
|
|
379
394
|
});
|
|
380
395
|
}
|
|
381
396
|
|
|
@@ -423,15 +438,16 @@ async function* streamResponse(
|
|
|
423
438
|
try {
|
|
424
439
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
425
440
|
for (const push of plan.pushes) {
|
|
426
|
-
const
|
|
441
|
+
const processed = await processPushCommitWithTrace(
|
|
427
442
|
ctx,
|
|
428
443
|
schema,
|
|
429
444
|
plan.resolved,
|
|
430
445
|
plan.header.clientId,
|
|
431
446
|
push,
|
|
432
447
|
);
|
|
448
|
+
const { frame } = processed;
|
|
433
449
|
if (events !== undefined) {
|
|
434
|
-
emitPushEvent(events, ctx, plan.header.clientId, push,
|
|
450
|
+
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
435
451
|
}
|
|
436
452
|
yield encodeResponseFrame(frame);
|
|
437
453
|
const details = pushResultDetailsFrame(frame);
|
package/src/postgres-storage.ts
CHANGED
|
@@ -193,6 +193,12 @@ function serializePushResult(result: StoredPushResult): unknown {
|
|
|
193
193
|
return {
|
|
194
194
|
status: result.status,
|
|
195
195
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
196
|
+
...(result.recordedAtMs !== undefined
|
|
197
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
198
|
+
: {}),
|
|
199
|
+
...(result.cacheIdentity !== undefined
|
|
200
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
201
|
+
: {}),
|
|
196
202
|
results: result.results.map((record) => {
|
|
197
203
|
if (record.status === 'conflict') {
|
|
198
204
|
return {
|
|
@@ -223,6 +229,8 @@ function deserializePushResult(value: unknown): StoredPushResult {
|
|
|
223
229
|
const parsed = value as {
|
|
224
230
|
status: 'applied' | 'rejected';
|
|
225
231
|
commitSeq?: number;
|
|
232
|
+
recordedAtMs?: number;
|
|
233
|
+
cacheIdentity?: string;
|
|
226
234
|
results: SerializedResult[];
|
|
227
235
|
};
|
|
228
236
|
const results: PushOperationResult[] = parsed.results.map((record) => {
|
|
@@ -251,6 +259,12 @@ function deserializePushResult(value: unknown): StoredPushResult {
|
|
|
251
259
|
return {
|
|
252
260
|
status: parsed.status,
|
|
253
261
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
262
|
+
...(parsed.recordedAtMs !== undefined
|
|
263
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
264
|
+
: {}),
|
|
265
|
+
...(parsed.cacheIdentity !== undefined
|
|
266
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
267
|
+
: {}),
|
|
254
268
|
results,
|
|
255
269
|
};
|
|
256
270
|
}
|
package/src/push.ts
CHANGED
|
@@ -750,6 +750,42 @@ function resultFrame(
|
|
|
750
750
|
};
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
export interface ProcessedPushCommit {
|
|
754
|
+
readonly frame: PushResultFrame;
|
|
755
|
+
/** True when this request observed an already-recorded idempotency outcome. */
|
|
756
|
+
readonly replayed: boolean;
|
|
757
|
+
readonly recordedAtMs?: number;
|
|
758
|
+
readonly cacheIdentity?: string;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function processedPushCommit(
|
|
762
|
+
clientCommitId: string,
|
|
763
|
+
stored: StoredPushResult,
|
|
764
|
+
replayed: boolean,
|
|
765
|
+
): ProcessedPushCommit {
|
|
766
|
+
return {
|
|
767
|
+
frame: resultFrame(clientCommitId, stored, replayed),
|
|
768
|
+
replayed,
|
|
769
|
+
...(stored.recordedAtMs !== undefined
|
|
770
|
+
? { recordedAtMs: stored.recordedAtMs }
|
|
771
|
+
: {}),
|
|
772
|
+
...(stored.cacheIdentity !== undefined
|
|
773
|
+
? { cacheIdentity: stored.cacheIdentity }
|
|
774
|
+
: {}),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function newStoredPushResult(
|
|
779
|
+
recordedAtMs: number,
|
|
780
|
+
result: Omit<StoredPushResult, 'recordedAtMs' | 'cacheIdentity'>,
|
|
781
|
+
): StoredPushResult {
|
|
782
|
+
return {
|
|
783
|
+
...result,
|
|
784
|
+
recordedAtMs,
|
|
785
|
+
cacheIdentity: crypto.randomUUID(),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
753
789
|
function idempotencyCacheMissFrame(
|
|
754
790
|
clientCommitId: string,
|
|
755
791
|
error: SyncError,
|
|
@@ -776,7 +812,7 @@ async function persistRejectedPushResult(
|
|
|
776
812
|
clientId: string,
|
|
777
813
|
clientCommitId: string,
|
|
778
814
|
stored: StoredPushResult,
|
|
779
|
-
): Promise<StoredPushResult> {
|
|
815
|
+
): Promise<{ readonly stored: StoredPushResult; readonly replayed: boolean }> {
|
|
780
816
|
const rejectionTx = await storage.begin(partition);
|
|
781
817
|
try {
|
|
782
818
|
await rejectionTx.putPushResult(clientId, clientCommitId, stored);
|
|
@@ -793,7 +829,10 @@ async function persistRejectedPushResult(
|
|
|
793
829
|
if (canonical === undefined) {
|
|
794
830
|
throw new Error('push rejection finalization did not persist an outcome');
|
|
795
831
|
}
|
|
796
|
-
return
|
|
832
|
+
return {
|
|
833
|
+
stored: canonical,
|
|
834
|
+
replayed: canonical.cacheIdentity !== stored.cacheIdentity,
|
|
835
|
+
};
|
|
797
836
|
}
|
|
798
837
|
|
|
799
838
|
export interface AppliedCommitEvent {
|
|
@@ -811,6 +850,23 @@ export async function processPushCommit(
|
|
|
811
850
|
clientId: string,
|
|
812
851
|
frame: PushCommitFrame,
|
|
813
852
|
): Promise<PushResultFrame> {
|
|
853
|
+
return (
|
|
854
|
+
await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)
|
|
855
|
+
).frame;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
860
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
861
|
+
* cache provenance needed by structured events and server helpers.
|
|
862
|
+
*/
|
|
863
|
+
export async function processPushCommitWithTrace(
|
|
864
|
+
ctx: SyncRequestContext,
|
|
865
|
+
schema: CompiledSchema,
|
|
866
|
+
resolved: ResolvedScopes,
|
|
867
|
+
clientId: string,
|
|
868
|
+
frame: PushCommitFrame,
|
|
869
|
+
): Promise<ProcessedPushCommit> {
|
|
814
870
|
const { storage, partition } = ctx;
|
|
815
871
|
let persisted: StoredPushResult | undefined;
|
|
816
872
|
try {
|
|
@@ -826,12 +882,15 @@ export async function processPushCommit(
|
|
|
826
882
|
) {
|
|
827
883
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
828
884
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
829
|
-
return
|
|
885
|
+
return {
|
|
886
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
887
|
+
replayed: false,
|
|
888
|
+
};
|
|
830
889
|
}
|
|
831
890
|
throw error;
|
|
832
891
|
}
|
|
833
892
|
if (persisted !== undefined) {
|
|
834
|
-
return
|
|
893
|
+
return processedPushCommit(frame.clientCommitId, persisted, true);
|
|
835
894
|
}
|
|
836
895
|
|
|
837
896
|
const createdAtMs = clockOf(ctx)();
|
|
@@ -863,7 +922,11 @@ export async function processPushCommit(
|
|
|
863
922
|
);
|
|
864
923
|
if (serializedPersisted !== undefined) {
|
|
865
924
|
await tx.rollback();
|
|
866
|
-
return
|
|
925
|
+
return processedPushCommit(
|
|
926
|
+
frame.clientCommitId,
|
|
927
|
+
serializedPersisted,
|
|
928
|
+
true,
|
|
929
|
+
);
|
|
867
930
|
}
|
|
868
931
|
} catch (error) {
|
|
869
932
|
if (
|
|
@@ -871,7 +934,10 @@ export async function processPushCommit(
|
|
|
871
934
|
error.code === 'sync.idempotency_cache_miss'
|
|
872
935
|
) {
|
|
873
936
|
await tx.rollback();
|
|
874
|
-
return
|
|
937
|
+
return {
|
|
938
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
939
|
+
replayed: false,
|
|
940
|
+
};
|
|
875
941
|
}
|
|
876
942
|
throw error;
|
|
877
943
|
}
|
|
@@ -923,10 +989,10 @@ export async function processPushCommit(
|
|
|
923
989
|
if (terminated !== undefined) {
|
|
924
990
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
925
991
|
// every write of the commit rolls back.
|
|
926
|
-
const stored
|
|
992
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
927
993
|
status: 'rejected',
|
|
928
994
|
results: [terminated],
|
|
929
|
-
};
|
|
995
|
+
});
|
|
930
996
|
if (commitValidator !== undefined) {
|
|
931
997
|
// Discard candidate rows and persist the rejection while retaining the
|
|
932
998
|
// same partition lock. This closes the duplicate-request race between
|
|
@@ -946,13 +1012,13 @@ export async function processPushCommit(
|
|
|
946
1012
|
frame.clientCommitId,
|
|
947
1013
|
stored,
|
|
948
1014
|
);
|
|
949
|
-
return
|
|
1015
|
+
return processedPushCommit(
|
|
950
1016
|
frame.clientCommitId,
|
|
951
|
-
canonical,
|
|
952
|
-
canonical
|
|
1017
|
+
canonical.stored,
|
|
1018
|
+
canonical.replayed,
|
|
953
1019
|
);
|
|
954
1020
|
}
|
|
955
|
-
return
|
|
1021
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
956
1022
|
}
|
|
957
1023
|
|
|
958
1024
|
const commitSeq = await tx.appendCommit({
|
|
@@ -962,7 +1028,11 @@ export async function processPushCommit(
|
|
|
962
1028
|
createdAtMs,
|
|
963
1029
|
changes,
|
|
964
1030
|
});
|
|
965
|
-
const stored
|
|
1031
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
1032
|
+
status: 'applied',
|
|
1033
|
+
commitSeq,
|
|
1034
|
+
results,
|
|
1035
|
+
});
|
|
966
1036
|
await tx.putPushResult(clientId, frame.clientCommitId, stored);
|
|
967
1037
|
await tx.commit();
|
|
968
1038
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
@@ -973,11 +1043,11 @@ export async function processPushCommit(
|
|
|
973
1043
|
changes,
|
|
974
1044
|
});
|
|
975
1045
|
}
|
|
976
|
-
return
|
|
1046
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
977
1047
|
} catch (error) {
|
|
978
1048
|
await tx.rollback();
|
|
979
1049
|
if (error instanceof StorageConstraintError) {
|
|
980
|
-
const stored
|
|
1050
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
981
1051
|
status: 'rejected',
|
|
982
1052
|
results: [
|
|
983
1053
|
{
|
|
@@ -988,7 +1058,7 @@ export async function processPushCommit(
|
|
|
988
1058
|
retryable: false,
|
|
989
1059
|
},
|
|
990
1060
|
],
|
|
991
|
-
};
|
|
1061
|
+
});
|
|
992
1062
|
const canonical = await persistRejectedPushResult(
|
|
993
1063
|
storage,
|
|
994
1064
|
partition,
|
|
@@ -996,7 +1066,11 @@ export async function processPushCommit(
|
|
|
996
1066
|
frame.clientCommitId,
|
|
997
1067
|
stored,
|
|
998
1068
|
);
|
|
999
|
-
return
|
|
1069
|
+
return processedPushCommit(
|
|
1070
|
+
frame.clientCommitId,
|
|
1071
|
+
canonical.stored,
|
|
1072
|
+
canonical.replayed,
|
|
1073
|
+
);
|
|
1000
1074
|
}
|
|
1001
1075
|
throw error;
|
|
1002
1076
|
}
|
package/src/seed.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
} from '@syncular/core';
|
|
22
22
|
import type { SyncServerConfig } from './context';
|
|
23
23
|
import { SyncError } from './errors';
|
|
24
|
+
import type { SyncularServerEvent, SyncularServerEvents } from './events';
|
|
25
|
+
import { composeEvents } from './events-ring';
|
|
24
26
|
import { handleSyncRequest } from './handler';
|
|
25
27
|
|
|
26
28
|
/** One app-shaped seed mutation — the same vocabulary as client mutations. */
|
|
@@ -54,6 +56,48 @@ export interface SeedTarget {
|
|
|
54
56
|
readonly commitId?: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
export interface SeedMutationErrorOptions {
|
|
60
|
+
readonly clientId: string;
|
|
61
|
+
readonly clientCommitId: string;
|
|
62
|
+
readonly opIndex: number;
|
|
63
|
+
readonly code: string;
|
|
64
|
+
readonly replayed: boolean;
|
|
65
|
+
readonly retryable: boolean;
|
|
66
|
+
readonly message: string;
|
|
67
|
+
readonly recordedAtMs?: number;
|
|
68
|
+
readonly cacheIdentity?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
72
|
+
export class SeedMutationError extends Error {
|
|
73
|
+
override readonly name = 'SeedMutationError';
|
|
74
|
+
readonly clientId: string;
|
|
75
|
+
readonly clientCommitId: string;
|
|
76
|
+
readonly opIndex: number;
|
|
77
|
+
/** Exact protocol or host-validator rejection code. */
|
|
78
|
+
readonly code: string;
|
|
79
|
+
readonly replayed: boolean;
|
|
80
|
+
readonly retryable: boolean;
|
|
81
|
+
readonly recordedAtMs?: number;
|
|
82
|
+
readonly cacheIdentity?: string;
|
|
83
|
+
|
|
84
|
+
constructor(options: SeedMutationErrorOptions) {
|
|
85
|
+
super(options.message);
|
|
86
|
+
this.clientId = options.clientId;
|
|
87
|
+
this.clientCommitId = options.clientCommitId;
|
|
88
|
+
this.opIndex = options.opIndex;
|
|
89
|
+
this.code = options.code;
|
|
90
|
+
this.replayed = options.replayed;
|
|
91
|
+
this.retryable = options.retryable;
|
|
92
|
+
if (options.recordedAtMs !== undefined) {
|
|
93
|
+
this.recordedAtMs = options.recordedAtMs;
|
|
94
|
+
}
|
|
95
|
+
if (options.cacheIdentity !== undefined) {
|
|
96
|
+
this.cacheIdentity = options.cacheIdentity;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
57
101
|
const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
|
|
58
102
|
|
|
59
103
|
/** Pinned §12 schema alias used by generated row types and every client host. */
|
|
@@ -79,8 +123,8 @@ function snakeToCamel(name: string): string {
|
|
|
79
123
|
|
|
80
124
|
/**
|
|
81
125
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
82
|
-
* `
|
|
83
|
-
* seed fails loud
|
|
126
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
127
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
84
128
|
*/
|
|
85
129
|
export async function seedMutations(
|
|
86
130
|
config: SyncServerConfig,
|
|
@@ -154,13 +198,37 @@ export async function seedMutations(
|
|
|
154
198
|
accept: 0b0011,
|
|
155
199
|
},
|
|
156
200
|
];
|
|
201
|
+
type SeedTerminalEvent = Extract<
|
|
202
|
+
SyncularServerEvent,
|
|
203
|
+
{ type: 'push.rejected' | 'push.conflicted' }
|
|
204
|
+
>;
|
|
205
|
+
let terminalEvent: SeedTerminalEvent | undefined;
|
|
206
|
+
const capture: SyncularServerEvents = {
|
|
207
|
+
emit(event) {
|
|
208
|
+
if (
|
|
209
|
+
(event.type === 'push.rejected' || event.type === 'push.conflicted') &&
|
|
210
|
+
event.clientId === clientId &&
|
|
211
|
+
event.clientCommitId === clientCommitId
|
|
212
|
+
) {
|
|
213
|
+
terminalEvent = event;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
157
217
|
const response = await handleSyncRequest(
|
|
158
218
|
encodeMessage({
|
|
159
219
|
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
160
220
|
msgKind: 'request',
|
|
161
221
|
frames,
|
|
162
222
|
}),
|
|
163
|
-
{
|
|
223
|
+
{
|
|
224
|
+
...config,
|
|
225
|
+
partition: target.partition,
|
|
226
|
+
actorId: target.actorId,
|
|
227
|
+
events:
|
|
228
|
+
config.events === undefined
|
|
229
|
+
? capture
|
|
230
|
+
: composeEvents(config.events, capture),
|
|
231
|
+
},
|
|
164
232
|
);
|
|
165
233
|
|
|
166
234
|
// Fail loud: surface the first rejected/failed operation.
|
|
@@ -177,13 +245,30 @@ export async function seedMutations(
|
|
|
177
245
|
}
|
|
178
246
|
if (result.status === 'rejected') {
|
|
179
247
|
const failed = result.results.find((r) => r.status !== 'applied');
|
|
248
|
+
const code = failed?.code ?? 'sync.invalid_request';
|
|
249
|
+
const opIndex = failed?.opIndex ?? 0;
|
|
250
|
+
const retryable = failed?.status === 'error' ? failed.retryable : false;
|
|
180
251
|
const detail =
|
|
181
|
-
failed
|
|
182
|
-
?
|
|
183
|
-
:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
failed === undefined
|
|
253
|
+
? ''
|
|
254
|
+
: ` (op ${opIndex}: ${code} — ${failed.message})`;
|
|
255
|
+
const replayed = terminalEvent?.replay ?? false;
|
|
256
|
+
throw new SeedMutationError({
|
|
257
|
+
clientId,
|
|
258
|
+
clientCommitId,
|
|
259
|
+
opIndex,
|
|
260
|
+
code,
|
|
261
|
+
replayed,
|
|
262
|
+
retryable,
|
|
263
|
+
message: `seedMutations: the seed commit was rejected${
|
|
264
|
+
replayed ? ' (cached replay)' : ''
|
|
265
|
+
}${detail}`,
|
|
266
|
+
...(terminalEvent?.recordedAtMs !== undefined
|
|
267
|
+
? { recordedAtMs: terminalEvent.recordedAtMs }
|
|
268
|
+
: {}),
|
|
269
|
+
...(terminalEvent?.cacheIdentity !== undefined
|
|
270
|
+
? { cacheIdentity: terminalEvent.cacheIdentity }
|
|
271
|
+
: {}),
|
|
272
|
+
});
|
|
188
273
|
}
|
|
189
274
|
}
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -134,6 +134,12 @@ export function serializePushResult(result: StoredPushResult): string {
|
|
|
134
134
|
return JSON.stringify({
|
|
135
135
|
status: result.status,
|
|
136
136
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
137
|
+
...(result.recordedAtMs !== undefined
|
|
138
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
139
|
+
: {}),
|
|
140
|
+
...(result.cacheIdentity !== undefined
|
|
141
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
142
|
+
: {}),
|
|
137
143
|
results: result.results.map((record) => {
|
|
138
144
|
if (record.status === 'conflict') {
|
|
139
145
|
return {
|
|
@@ -164,6 +170,8 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
164
170
|
const parsed = JSON.parse(text) as {
|
|
165
171
|
status: 'applied' | 'rejected';
|
|
166
172
|
commitSeq?: number;
|
|
173
|
+
recordedAtMs?: number;
|
|
174
|
+
cacheIdentity?: string;
|
|
167
175
|
results: SerializedResult[];
|
|
168
176
|
};
|
|
169
177
|
const results: PushOperationResult[] = parsed.results.map((record) => {
|
|
@@ -192,6 +200,12 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
192
200
|
return {
|
|
193
201
|
status: parsed.status,
|
|
194
202
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
203
|
+
...(parsed.recordedAtMs !== undefined
|
|
204
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
205
|
+
: {}),
|
|
206
|
+
...(parsed.cacheIdentity !== undefined
|
|
207
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
208
|
+
: {}),
|
|
195
209
|
results,
|
|
196
210
|
};
|
|
197
211
|
}
|
package/src/storage.ts
CHANGED
|
@@ -70,6 +70,10 @@ export interface StoredPushResult {
|
|
|
70
70
|
readonly status: 'applied' | 'rejected';
|
|
71
71
|
/** Present iff `status` is `applied`. */
|
|
72
72
|
readonly commitSeq?: number;
|
|
73
|
+
/** Host clock when this terminal idempotency outcome was first recorded. */
|
|
74
|
+
readonly recordedAtMs?: number;
|
|
75
|
+
/** Privacy-safe identity used to distinguish this stored outcome from a race. */
|
|
76
|
+
readonly cacheIdentity?: string;
|
|
73
77
|
readonly results: readonly PushOperationResult[];
|
|
74
78
|
}
|
|
75
79
|
|