@syncular/server 0.30.10 → 0.30.12
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/dist/errors.js +6 -0
- package/dist/events.d.ts +7 -1
- package/dist/handler.js +17 -0
- package/package.json +2 -2
- package/src/errors.ts +6 -0
- package/src/events.ts +7 -1
- package/src/handler.ts +24 -0
package/dist/errors.js
CHANGED
|
@@ -171,6 +171,12 @@ export const ERROR_CATALOG = {
|
|
|
171
171
|
recommendedAction: 'retryLater',
|
|
172
172
|
httpStatus: 500,
|
|
173
173
|
},
|
|
174
|
+
'sync.storage_stale_read': {
|
|
175
|
+
category: 'internal',
|
|
176
|
+
retryable: false,
|
|
177
|
+
recommendedAction: 'inspectServer',
|
|
178
|
+
httpStatus: 503,
|
|
179
|
+
},
|
|
174
180
|
'sync.schema_not_ready': {
|
|
175
181
|
category: 'internal',
|
|
176
182
|
retryable: true,
|
package/dist/events.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - Timestamps and durations come from the ctx clock where one exists, so
|
|
14
14
|
* the conformance virtual-clock discipline stays intact.
|
|
15
15
|
*/
|
|
16
|
-
import type { WakeReason } from '@syncular/core';
|
|
16
|
+
import type { ScopeMap, WakeReason } from '@syncular/core';
|
|
17
17
|
/** One `POST /sync` request, emitted once per request after the response
|
|
18
18
|
* bytes are fully produced (or the request was rejected up front). */
|
|
19
19
|
export interface RequestHandledEvent {
|
|
@@ -141,6 +141,10 @@ export interface PullSubscriptionSummary {
|
|
|
141
141
|
readonly status: 'active' | 'revoked' | 'reset';
|
|
142
142
|
/** `none` for revoked/reset sections (no data half was produced). */
|
|
143
143
|
readonly mode: 'bootstrap' | 'incremental' | 'none';
|
|
144
|
+
readonly requestedScopes: ScopeMap;
|
|
145
|
+
readonly effectiveScopes: ScopeMap;
|
|
146
|
+
readonly firstCommitSeq?: number;
|
|
147
|
+
readonly lastCommitSeq?: number;
|
|
144
148
|
readonly fromCursor: number;
|
|
145
149
|
readonly nextCursor: number;
|
|
146
150
|
readonly commits: number;
|
|
@@ -154,6 +158,8 @@ export interface PullServedEvent {
|
|
|
154
158
|
readonly partition: string;
|
|
155
159
|
readonly actorId: string;
|
|
156
160
|
readonly clientId: string;
|
|
161
|
+
readonly acceptedThrough: number;
|
|
162
|
+
readonly storageMaxCommitSeq: number;
|
|
157
163
|
readonly subscriptions: readonly PullSubscriptionSummary[];
|
|
158
164
|
}
|
|
159
165
|
/** A direct segment download (§5.5), success or failure. */
|
package/dist/handler.js
CHANGED
|
@@ -335,8 +335,10 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
335
335
|
}
|
|
336
336
|
try {
|
|
337
337
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
338
|
+
let acceptedThrough = 0;
|
|
338
339
|
for (const push of plan.pushes) {
|
|
339
340
|
const processed = await processPushCommitWithTrace(ctx, compileSchema(ctx.schema), plan.resolved, plan.header.clientId, push, schema);
|
|
341
|
+
acceptedThrough = Math.max(acceptedThrough, processed.frame.commitSeq ?? 0);
|
|
340
342
|
const frame = {
|
|
341
343
|
...processed.frame,
|
|
342
344
|
results: processed.frame.results.map((result) => {
|
|
@@ -372,6 +374,8 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
372
374
|
const summaries = events !== undefined ? [] : undefined;
|
|
373
375
|
const limits = clampPullLimits(plan.pull);
|
|
374
376
|
const maxSeq = await ctx.storage.getMaxCommitSeq(ctx.partition);
|
|
377
|
+
if (maxSeq < acceptedThrough)
|
|
378
|
+
throw syncError('sync.storage_stale_read', 'storage returned a commit sequence older than the accepted push; disable query caching for sync storage', JSON.stringify({ acceptedThrough, storageMaxCommitSeq: maxSeq }));
|
|
375
379
|
const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
|
|
376
380
|
const prepared = await prepareSections(ctx, schema, limits, plan.subscriptions, maxSeq, horizonSeq, plan.logEpoch);
|
|
377
381
|
for (const [index, subscription] of plan.subscriptions.entries()) {
|
|
@@ -384,6 +388,9 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
384
388
|
let bootstrap = false;
|
|
385
389
|
let commits = 0;
|
|
386
390
|
let changes = 0;
|
|
391
|
+
let effectiveScopes = {};
|
|
392
|
+
let firstCommitSeq;
|
|
393
|
+
let lastCommitSeq;
|
|
387
394
|
let step = await section.next();
|
|
388
395
|
while (!step.done) {
|
|
389
396
|
const frame = step.value;
|
|
@@ -391,10 +398,13 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
391
398
|
if (frame.type === 'SUB_START') {
|
|
392
399
|
status = frame.status;
|
|
393
400
|
bootstrap = frame.bootstrap;
|
|
401
|
+
effectiveScopes = frame.effectiveScopes;
|
|
394
402
|
}
|
|
395
403
|
else if (frame.type === 'COMMIT') {
|
|
396
404
|
commits += 1;
|
|
397
405
|
changes += frame.changes.length;
|
|
406
|
+
firstCommitSeq ??= frame.commitSeq;
|
|
407
|
+
lastCommitSeq = frame.commitSeq;
|
|
398
408
|
}
|
|
399
409
|
}
|
|
400
410
|
yield encodeResponseFrame(frame, plan.wireVersion);
|
|
@@ -412,6 +422,11 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
412
422
|
: bootstrap
|
|
413
423
|
? 'bootstrap'
|
|
414
424
|
: 'incremental',
|
|
425
|
+
requestedScopes: subscription.frame.scopes,
|
|
426
|
+
effectiveScopes,
|
|
427
|
+
...(firstCommitSeq !== undefined && lastCommitSeq !== undefined
|
|
428
|
+
? { firstCommitSeq, lastCommitSeq }
|
|
429
|
+
: {}),
|
|
415
430
|
fromCursor: subscription.frame.cursor,
|
|
416
431
|
nextCursor: step.value.nextCursor,
|
|
417
432
|
commits,
|
|
@@ -427,6 +442,8 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
427
442
|
partition: ctx.partition,
|
|
428
443
|
actorId: ctx.actorId,
|
|
429
444
|
clientId: plan.header.clientId,
|
|
445
|
+
acceptedThrough,
|
|
446
|
+
storageMaxCommitSeq: maxSeq,
|
|
430
447
|
subscriptions: summaries,
|
|
431
448
|
});
|
|
432
449
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.12",
|
|
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.12"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/errors.ts
CHANGED
|
@@ -179,6 +179,12 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
179
179
|
recommendedAction: 'retryLater',
|
|
180
180
|
httpStatus: 500,
|
|
181
181
|
},
|
|
182
|
+
'sync.storage_stale_read': {
|
|
183
|
+
category: 'internal',
|
|
184
|
+
retryable: false,
|
|
185
|
+
recommendedAction: 'inspectServer',
|
|
186
|
+
httpStatus: 503,
|
|
187
|
+
},
|
|
182
188
|
'sync.schema_not_ready': {
|
|
183
189
|
category: 'internal',
|
|
184
190
|
retryable: true,
|
package/src/events.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - Timestamps and durations come from the ctx clock where one exists, so
|
|
14
14
|
* the conformance virtual-clock discipline stays intact.
|
|
15
15
|
*/
|
|
16
|
-
import type { WakeReason } from '@syncular/core';
|
|
16
|
+
import type { ScopeMap, WakeReason } from '@syncular/core';
|
|
17
17
|
|
|
18
18
|
/** One `POST /sync` request, emitted once per request after the response
|
|
19
19
|
* bytes are fully produced (or the request was rejected up front). */
|
|
@@ -155,6 +155,10 @@ export interface PullSubscriptionSummary {
|
|
|
155
155
|
readonly status: 'active' | 'revoked' | 'reset';
|
|
156
156
|
/** `none` for revoked/reset sections (no data half was produced). */
|
|
157
157
|
readonly mode: 'bootstrap' | 'incremental' | 'none';
|
|
158
|
+
readonly requestedScopes: ScopeMap;
|
|
159
|
+
readonly effectiveScopes: ScopeMap;
|
|
160
|
+
readonly firstCommitSeq?: number;
|
|
161
|
+
readonly lastCommitSeq?: number;
|
|
158
162
|
readonly fromCursor: number;
|
|
159
163
|
readonly nextCursor: number;
|
|
160
164
|
readonly commits: number;
|
|
@@ -169,6 +173,8 @@ export interface PullServedEvent {
|
|
|
169
173
|
readonly partition: string;
|
|
170
174
|
readonly actorId: string;
|
|
171
175
|
readonly clientId: string;
|
|
176
|
+
readonly acceptedThrough: number;
|
|
177
|
+
readonly storageMaxCommitSeq: number;
|
|
172
178
|
readonly subscriptions: readonly PullSubscriptionSummary[];
|
|
173
179
|
}
|
|
174
180
|
|
package/src/handler.ts
CHANGED
|
@@ -519,6 +519,7 @@ async function* streamResponse(
|
|
|
519
519
|
}
|
|
520
520
|
try {
|
|
521
521
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
522
|
+
let acceptedThrough = 0;
|
|
522
523
|
for (const push of plan.pushes) {
|
|
523
524
|
const processed = await processPushCommitWithTrace(
|
|
524
525
|
ctx,
|
|
@@ -528,6 +529,10 @@ async function* streamResponse(
|
|
|
528
529
|
push,
|
|
529
530
|
schema,
|
|
530
531
|
);
|
|
532
|
+
acceptedThrough = Math.max(
|
|
533
|
+
acceptedThrough,
|
|
534
|
+
processed.frame.commitSeq ?? 0,
|
|
535
|
+
);
|
|
531
536
|
const frame = {
|
|
532
537
|
...processed.frame,
|
|
533
538
|
results: processed.frame.results.map((result) => {
|
|
@@ -568,6 +573,12 @@ async function* streamResponse(
|
|
|
568
573
|
events !== undefined ? [] : undefined;
|
|
569
574
|
const limits = clampPullLimits(plan.pull);
|
|
570
575
|
const maxSeq = await ctx.storage.getMaxCommitSeq(ctx.partition);
|
|
576
|
+
if (maxSeq < acceptedThrough)
|
|
577
|
+
throw syncError(
|
|
578
|
+
'sync.storage_stale_read',
|
|
579
|
+
'storage returned a commit sequence older than the accepted push; disable query caching for sync storage',
|
|
580
|
+
JSON.stringify({ acceptedThrough, storageMaxCommitSeq: maxSeq }),
|
|
581
|
+
);
|
|
571
582
|
const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
|
|
572
583
|
const prepared = await prepareSections(
|
|
573
584
|
ctx,
|
|
@@ -597,6 +608,9 @@ async function* streamResponse(
|
|
|
597
608
|
let bootstrap = false;
|
|
598
609
|
let commits = 0;
|
|
599
610
|
let changes = 0;
|
|
611
|
+
let effectiveScopes: ScopeMap = {};
|
|
612
|
+
let firstCommitSeq: number | undefined;
|
|
613
|
+
let lastCommitSeq: number | undefined;
|
|
600
614
|
let step = await section.next();
|
|
601
615
|
while (!step.done) {
|
|
602
616
|
const frame = step.value as ResponseFrame;
|
|
@@ -604,9 +618,12 @@ async function* streamResponse(
|
|
|
604
618
|
if (frame.type === 'SUB_START') {
|
|
605
619
|
status = frame.status;
|
|
606
620
|
bootstrap = frame.bootstrap;
|
|
621
|
+
effectiveScopes = frame.effectiveScopes;
|
|
607
622
|
} else if (frame.type === 'COMMIT') {
|
|
608
623
|
commits += 1;
|
|
609
624
|
changes += frame.changes.length;
|
|
625
|
+
firstCommitSeq ??= frame.commitSeq;
|
|
626
|
+
lastCommitSeq = frame.commitSeq;
|
|
610
627
|
}
|
|
611
628
|
}
|
|
612
629
|
yield encodeResponseFrame(frame, plan.wireVersion);
|
|
@@ -624,6 +641,11 @@ async function* streamResponse(
|
|
|
624
641
|
: bootstrap
|
|
625
642
|
? 'bootstrap'
|
|
626
643
|
: 'incremental',
|
|
644
|
+
requestedScopes: subscription.frame.scopes,
|
|
645
|
+
effectiveScopes,
|
|
646
|
+
...(firstCommitSeq !== undefined && lastCommitSeq !== undefined
|
|
647
|
+
? { firstCommitSeq, lastCommitSeq }
|
|
648
|
+
: {}),
|
|
627
649
|
fromCursor: subscription.frame.cursor,
|
|
628
650
|
nextCursor: step.value.nextCursor,
|
|
629
651
|
commits,
|
|
@@ -639,6 +661,8 @@ async function* streamResponse(
|
|
|
639
661
|
partition: ctx.partition,
|
|
640
662
|
actorId: ctx.actorId,
|
|
641
663
|
clientId: plan.header.clientId,
|
|
664
|
+
acceptedThrough,
|
|
665
|
+
storageMaxCommitSeq: maxSeq,
|
|
642
666
|
subscriptions: summaries,
|
|
643
667
|
});
|
|
644
668
|
}
|