@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 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.10",
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.10"
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
  }