@syncular/server 0.40.0 → 0.41.0

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.
@@ -42,7 +42,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
42
42
  */
43
43
  import { type ScopeMap } from '@syncular/core';
44
44
  import type { CompiledSchema, CompiledTable } from './schema.js';
45
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
45
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, ClientRecordWriteOptions, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
46
46
  export interface D1PreparedStatement {
47
47
  bind(...values: unknown[]): D1PreparedStatement;
48
48
  first<T = Record<string, unknown>>(): Promise<T | null>;
@@ -123,7 +123,7 @@ export declare class D1ServerStorage implements ServerStorage {
123
123
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
124
124
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
125
125
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
126
- putClientRecord(partition: string, record: ClientRecord): Promise<void>;
126
+ putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
127
127
  recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
128
128
  advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
129
129
  updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
@@ -1605,10 +1605,11 @@ export class D1ServerStorage {
1605
1605
  ...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
1606
1606
  };
1607
1607
  }
1608
- async putClientRecord(partition, record) {
1608
+ async putClientRecord(partition, record, options) {
1609
1609
  // Update the existing record in place, so the table keeps one row per
1610
1610
  // (partition, client_id). Every non-key field is written from the new
1611
1611
  // record; a delete-and-insert would drop and recreate the row instead.
1612
+ // `keepRoundOutcome` leaves the round-state columns alone (§1.5, §4.5).
1612
1613
  await this.#db
1613
1614
  .prepare(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
1614
1615
  ON CONFLICT(partition, client_id) DO UPDATE SET
@@ -1617,9 +1618,11 @@ export class D1ServerStorage {
1617
1618
  cursor=excluded.cursor,
1618
1619
  subscriptions=excluded.subscriptions,
1619
1620
  updated_at_ms=excluded.updated_at_ms,
1620
- last_round_at_ms=excluded.last_round_at_ms,
1621
+ last_round_at_ms=excluded.last_round_at_ms${options?.keepRoundOutcome === true
1622
+ ? ''
1623
+ : `,
1621
1624
  last_error_code=NULL,
1622
- consecutive_failures=0`)
1625
+ consecutive_failures=0`}`)
1623
1626
  .bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs, record.updatedAtMs)
1624
1627
  .run();
1625
1628
  }
package/dist/handler.js CHANGED
@@ -106,10 +106,13 @@ async function planRequest(request, ctx, schema, registry) {
106
106
  throw syncError('sync.invalid_request', 'missing REQ_HEADER');
107
107
  }
108
108
  const pushes = [];
109
+ let roundFailure;
109
110
  let pull;
110
111
  const subFrames = [];
111
112
  for (const frame of request.frames) {
112
- if (frame.type === 'PUSH_COMMIT')
113
+ if (frame.type === 'ROUND_FAILURE')
114
+ roundFailure = frame;
115
+ else if (frame.type === 'PUSH_COMMIT')
113
116
  pushes.push(frame);
114
117
  else if (frame.type === 'PULL_HEADER')
115
118
  pull = frame;
@@ -130,6 +133,7 @@ async function planRequest(request, ctx, schema, registry) {
130
133
  logEpoch: registry.logEpoch,
131
134
  epochReset,
132
135
  header,
136
+ roundFailure,
133
137
  pushes,
134
138
  pull,
135
139
  subscriptions: [],
@@ -144,6 +148,7 @@ async function planRequest(request, ctx, schema, registry) {
144
148
  logEpoch: registry.logEpoch,
145
149
  epochReset: true,
146
150
  header,
151
+ roundFailure,
147
152
  pushes: [],
148
153
  pull: undefined,
149
154
  subscriptions: [],
@@ -212,6 +217,7 @@ async function planRequest(request, ctx, schema, registry) {
212
217
  logEpoch: registry.logEpoch,
213
218
  epochReset: false,
214
219
  header,
220
+ roundFailure,
215
221
  pushes,
216
222
  pull,
217
223
  subscriptions,
@@ -483,7 +489,11 @@ async function* streamResponse(plan, ctx, schema, report) {
483
489
  cursor,
484
490
  updatedAtMs: clockOf(ctx)(),
485
491
  subscriptions,
486
- });
492
+ },
493
+ // §4.5: a round that carried a `ROUND_FAILURE` report completes without
494
+ // clearing the reported code and streak; the next request without the
495
+ // frame clears them as a plain completed round does.
496
+ plan.roundFailure !== undefined ? { keepRoundOutcome: true } : undefined);
487
497
  if (ctx.realtime?.notifySubscriptions !== undefined &&
488
498
  JSON.stringify((previous?.subscriptions ?? []).map((s) => [s.id, s.table, s.scopes])) !== JSON.stringify(subscriptions.map((s) => [s.id, s.table, s.scopes]))) {
489
499
  await ctx.realtime.notifySubscriptions(ctx.partition, plan.header.clientId);
@@ -583,10 +593,15 @@ function requestClientId(bytes) {
583
593
  * `onError` and never replaces the round's own outcome.
584
594
  */
585
595
  async function recordRoundFailure(ctx, clientId, error) {
596
+ await recordRoundFailureCode(ctx, clientId, error instanceof SyncError ? error.code : 'sync.internal_error');
597
+ }
598
+ /** As `recordRoundFailure`, for a code that is not a thrown catalog error
599
+ * (a `ROUND_FAILURE` report, §1.5). */
600
+ async function recordRoundFailureCode(ctx, clientId, code) {
586
601
  if (clientId === undefined)
587
602
  return;
588
603
  try {
589
- await ctx.storage.recordClientRoundFailure(ctx.partition, clientId, ctx.actorId, error instanceof SyncError ? error.code : 'sync.internal_error', clockOf(ctx)());
604
+ await ctx.storage.recordClientRoundFailure(ctx.partition, clientId, ctx.actorId, code, clockOf(ctx)());
590
605
  }
591
606
  catch (failure) {
592
607
  reportError(failure, ctx.onError, 'sync');
@@ -648,6 +663,12 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
648
663
  candidate.version === header.schemaVersion);
649
664
  const served = selected ?? schema;
650
665
  const plan = await planRequest(request, ctx, served, registry);
666
+ // §1.5/§4.5: record the reported round failure on the existing record
667
+ // before the round runs (it never creates one, never touches another
668
+ // actor's record). The completion of this round leaves it in place.
669
+ if (plan.roundFailure !== undefined) {
670
+ await recordRoundFailureCode(ctx, plan.header.clientId, plan.roundFailure.code);
671
+ }
651
672
  const report = { outcome: 'ok' };
652
673
  // RFC 0007 read-verify-refuse for the streamed read path: the generator
653
674
  // builds the whole response (its data reads) before yielding the first
@@ -39,7 +39,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
39
39
  import type { ScopeMap } from '@syncular/core';
40
40
  import { type PgExecutor } from './pg-executor.js';
41
41
  import type { CompiledSchema, CompiledTable } from './schema.js';
42
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
42
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, ClientRecordWriteOptions, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
43
43
  /**
44
44
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
45
45
  *
@@ -129,7 +129,7 @@ export declare class PostgresServerStorage implements ServerStorage {
129
129
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
130
130
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
131
131
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
132
- putClientRecord(partition: string, record: ClientRecord): Promise<void>;
132
+ putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
133
133
  recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
134
134
  advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
135
135
  updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
@@ -1827,7 +1827,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1827
1827
  : asNumber(record.last_round_at_ms), record.last_error_code, asNumber(record.consecutive_failures)),
1828
1828
  };
1829
1829
  }
1830
- async putClientRecord(partition, record) {
1830
+ async putClientRecord(partition, record, options) {
1831
1831
  await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
1832
1832
  VALUES ($1,$2,$3,$4,$5,$6,$7,$7)
1833
1833
  ON CONFLICT (partition, client_id) DO UPDATE
@@ -1835,8 +1835,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1835
1835
  cursor=EXCLUDED.cursor,
1836
1836
  subscriptions=EXCLUDED.subscriptions,
1837
1837
  updated_at_ms=EXCLUDED.updated_at_ms,
1838
- last_round_at_ms=EXCLUDED.last_round_at_ms,
1839
- last_error_code=NULL, consecutive_failures=0`, [
1838
+ last_round_at_ms=EXCLUDED.last_round_at_ms${options?.keepRoundOutcome === true
1839
+ ? ''
1840
+ : ',\n last_error_code=NULL, consecutive_failures=0'}`, [
1840
1841
  partition,
1841
1842
  record.clientId,
1842
1843
  record.actorId,
@@ -2,7 +2,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
2
  import type { ScopeMap } from '@syncular/core';
3
3
  import type { CompiledSchema, CompiledTable } from './schema.js';
4
4
  import { type SqliteDatabase } from './sqlite-driver.js';
5
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
5
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, ClientRecordWriteOptions, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
6
6
  export declare class SqliteServerStorage implements ServerStorage {
7
7
  #private;
8
8
  readonly db: SqliteDatabase;
@@ -56,7 +56,7 @@ export declare class SqliteServerStorage implements ServerStorage {
56
56
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
57
57
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
58
58
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
59
- putClientRecord(partition: string, record: ClientRecord): Promise<void>;
59
+ putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
60
60
  recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
61
61
  advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
62
62
  updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
@@ -1175,9 +1175,20 @@ END`);
1175
1175
  ...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
1176
1176
  };
1177
1177
  }
1178
- async putClientRecord(partition, record) {
1178
+ async putClientRecord(partition, record, options) {
1179
1179
  this.db
1180
- .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)')
1180
+ .query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
1181
+ ON CONFLICT(partition, client_id) DO UPDATE SET
1182
+ actor_id=excluded.actor_id,
1183
+ wire_version=excluded.wire_version,
1184
+ cursor=excluded.cursor,
1185
+ subscriptions=excluded.subscriptions,
1186
+ updated_at_ms=excluded.updated_at_ms,
1187
+ last_round_at_ms=excluded.last_round_at_ms${options?.keepRoundOutcome === true
1188
+ ? ''
1189
+ : `,
1190
+ last_error_code=NULL,
1191
+ consecutive_failures=0`}`)
1181
1192
  .run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs, record.updatedAtMs);
1182
1193
  }
1183
1194
  async recordClientRoundFailure(partition, clientId, actorId, errorCode, atMs) {
package/dist/storage.d.ts CHANGED
@@ -242,6 +242,15 @@ export interface ClientRecord {
242
242
  /** Rounds that failed in a row since the last successful round. */
243
243
  readonly consecutiveFailures?: number;
244
244
  }
245
+ /** `putClientRecord` options. */
246
+ export interface ClientRecordWriteOptions {
247
+ /**
248
+ * Leave the stored round outcome (`lastErrorCode`, `consecutiveFailures`)
249
+ * untouched. Set for a completed round that carried a `ROUND_FAILURE` report
250
+ * (§1.5, §4.5). `lastRoundAtMs` still moves to this round's completion.
251
+ */
252
+ readonly keepRoundOutcome?: boolean;
253
+ }
245
254
  /** The round-state fields of a stored `sync_clients` row. */
246
255
  export declare function clientRoundState(lastRoundAtMs: number | null, lastErrorCode: string | null, consecutiveFailures: number): Pick<ClientRecord, 'lastRoundAtMs' | 'lastErrorCode' | 'consecutiveFailures'>;
247
256
  export interface CommitWindowQuery {
@@ -690,9 +699,11 @@ export interface ServerStorage {
690
699
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
691
700
  /**
692
701
  * Write the record for a completed round. The round-state fields reset: the
693
- * stored last round is a success at `updatedAtMs` with no failures.
702
+ * stored last round is a success at `updatedAtMs` with no failures — unless
703
+ * `keepRoundOutcome` is set, which leaves the reported code and streak in
704
+ * place (the round carried a `ROUND_FAILURE` report, §1.5/§4.5).
694
705
  */
695
- putClientRecord(partition: string, record: ClientRecord): Promise<void>;
706
+ putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
696
707
  /**
697
708
  * Record a failed round on an existing record: set `lastRoundAtMs` and
698
709
  * `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
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.40.0"
71
+ "@syncular/core": "0.41.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -116,6 +116,7 @@ import type {
116
116
  ClientCursorInfo,
117
117
  ClientPushResult,
118
118
  ClientRecord,
119
+ ClientRecordWriteOptions,
119
120
  ClientSubscription,
120
121
  CommitChangeSummary,
121
122
  CommitDetail,
@@ -2416,10 +2417,12 @@ export class D1ServerStorage implements ServerStorage {
2416
2417
  async putClientRecord(
2417
2418
  partition: string,
2418
2419
  record: ClientRecord,
2420
+ options?: ClientRecordWriteOptions,
2419
2421
  ): Promise<void> {
2420
2422
  // Update the existing record in place, so the table keeps one row per
2421
2423
  // (partition, client_id). Every non-key field is written from the new
2422
2424
  // record; a delete-and-insert would drop and recreate the row instead.
2425
+ // `keepRoundOutcome` leaves the round-state columns alone (§1.5, §4.5).
2423
2426
  await this.#db
2424
2427
  .prepare(
2425
2428
  `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
@@ -2429,9 +2432,13 @@ export class D1ServerStorage implements ServerStorage {
2429
2432
  cursor=excluded.cursor,
2430
2433
  subscriptions=excluded.subscriptions,
2431
2434
  updated_at_ms=excluded.updated_at_ms,
2432
- last_round_at_ms=excluded.last_round_at_ms,
2435
+ last_round_at_ms=excluded.last_round_at_ms${
2436
+ options?.keepRoundOutcome === true
2437
+ ? ''
2438
+ : `,
2433
2439
  last_error_code=NULL,
2434
- consecutive_failures=0`,
2440
+ consecutive_failures=0`
2441
+ }`,
2435
2442
  )
2436
2443
  .bind(
2437
2444
  partition,
package/src/handler.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type ReqHeaderFrame,
20
20
  type RequestMessage,
21
21
  type ResponseFrame,
22
+ type RoundFailureFrame,
22
23
  type ScopeMap,
23
24
  type SubscriptionFrame,
24
25
  } from '@syncular/core';
@@ -70,6 +71,8 @@ interface RequestPlan {
70
71
  readonly logEpoch: string;
71
72
  readonly epochReset: boolean;
72
73
  readonly header: ReqHeaderFrame;
74
+ /** §1.5: the code this request reports for its last unseen round failure. */
75
+ readonly roundFailure: RoundFailureFrame | undefined;
73
76
  readonly pushes: readonly PushCommitFrame[];
74
77
  readonly pull: PullHeaderFrame | undefined;
75
78
  readonly subscriptions: readonly SubscriptionPlan[];
@@ -212,10 +215,12 @@ async function planRequest(
212
215
  throw syncError('sync.invalid_request', 'missing REQ_HEADER');
213
216
  }
214
217
  const pushes: PushCommitFrame[] = [];
218
+ let roundFailure: RoundFailureFrame | undefined;
215
219
  let pull: PullHeaderFrame | undefined;
216
220
  const subFrames: SubscriptionFrame[] = [];
217
221
  for (const frame of request.frames) {
218
- if (frame.type === 'PUSH_COMMIT') pushes.push(frame);
222
+ if (frame.type === 'ROUND_FAILURE') roundFailure = frame;
223
+ else if (frame.type === 'PUSH_COMMIT') pushes.push(frame);
219
224
  else if (frame.type === 'PULL_HEADER') pull = frame;
220
225
  else if (frame.type === 'SUBSCRIPTION') subFrames.push(frame);
221
226
  }
@@ -241,6 +246,7 @@ async function planRequest(
241
246
  logEpoch: registry.logEpoch,
242
247
  epochReset,
243
248
  header,
249
+ roundFailure,
244
250
  pushes,
245
251
  pull,
246
252
  subscriptions: [],
@@ -256,6 +262,7 @@ async function planRequest(
256
262
  logEpoch: registry.logEpoch,
257
263
  epochReset: true,
258
264
  header,
265
+ roundFailure,
259
266
  pushes: [],
260
267
  pull: undefined,
261
268
  subscriptions: [],
@@ -362,6 +369,7 @@ async function planRequest(
362
369
  logEpoch: registry.logEpoch,
363
370
  epochReset: false,
364
371
  header,
372
+ roundFailure,
365
373
  pushes,
366
374
  pull,
367
375
  subscriptions,
@@ -709,14 +717,21 @@ async function* streamResponse(
709
717
  : [...(previous?.subscriptions ?? [])];
710
718
  const cursor =
711
719
  cursors.length > 0 ? Math.min(...cursors) : (previous?.cursor ?? -1);
712
- await ctx.storage.putClientRecord(ctx.partition, {
713
- clientId: plan.header.clientId,
714
- actorId: ctx.actorId,
715
- wireVersion: plan.wireVersion,
716
- cursor,
717
- updatedAtMs: clockOf(ctx)(),
718
- subscriptions,
719
- });
720
+ await ctx.storage.putClientRecord(
721
+ ctx.partition,
722
+ {
723
+ clientId: plan.header.clientId,
724
+ actorId: ctx.actorId,
725
+ wireVersion: plan.wireVersion,
726
+ cursor,
727
+ updatedAtMs: clockOf(ctx)(),
728
+ subscriptions,
729
+ },
730
+ // §4.5: a round that carried a `ROUND_FAILURE` report completes without
731
+ // clearing the reported code and streak; the next request without the
732
+ // frame clears them as a plain completed round does.
733
+ plan.roundFailure !== undefined ? { keepRoundOutcome: true } : undefined,
734
+ );
720
735
  if (
721
736
  ctx.realtime?.notifySubscriptions !== undefined &&
722
737
  JSON.stringify(
@@ -831,6 +846,20 @@ async function recordRoundFailure(
831
846
  ctx: SyncRequestContext,
832
847
  clientId: string | undefined,
833
848
  error: unknown,
849
+ ): Promise<void> {
850
+ await recordRoundFailureCode(
851
+ ctx,
852
+ clientId,
853
+ error instanceof SyncError ? error.code : 'sync.internal_error',
854
+ );
855
+ }
856
+
857
+ /** As `recordRoundFailure`, for a code that is not a thrown catalog error
858
+ * (a `ROUND_FAILURE` report, §1.5). */
859
+ async function recordRoundFailureCode(
860
+ ctx: SyncRequestContext,
861
+ clientId: string | undefined,
862
+ code: string,
834
863
  ): Promise<void> {
835
864
  if (clientId === undefined) return;
836
865
  try {
@@ -838,7 +867,7 @@ async function recordRoundFailure(
838
867
  ctx.partition,
839
868
  clientId,
840
869
  ctx.actorId,
841
- error instanceof SyncError ? error.code : 'sync.internal_error',
870
+ code,
842
871
  clockOf(ctx)(),
843
872
  );
844
873
  } catch (failure) {
@@ -917,6 +946,16 @@ async function createStreamCore(
917
946
  );
918
947
  const served = selected ?? schema;
919
948
  const plan = await planRequest(request, ctx, served, registry);
949
+ // §1.5/§4.5: record the reported round failure on the existing record
950
+ // before the round runs (it never creates one, never touches another
951
+ // actor's record). The completion of this round leaves it in place.
952
+ if (plan.roundFailure !== undefined) {
953
+ await recordRoundFailureCode(
954
+ ctx,
955
+ plan.header.clientId,
956
+ plan.roundFailure.code,
957
+ );
958
+ }
920
959
  const report: RequestReport = { outcome: 'ok' };
921
960
  // RFC 0007 read-verify-refuse for the streamed read path: the generator
922
961
  // builds the whole response (its data reads) before yielding the first
@@ -105,6 +105,7 @@ import type {
105
105
  ClientCursorInfo,
106
106
  ClientPushResult,
107
107
  ClientRecord,
108
+ ClientRecordWriteOptions,
108
109
  ClientSubscription,
109
110
  CommitChangeSummary,
110
111
  CommitDetail,
@@ -2778,6 +2779,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2778
2779
  async putClientRecord(
2779
2780
  partition: string,
2780
2781
  record: ClientRecord,
2782
+ options?: ClientRecordWriteOptions,
2781
2783
  ): Promise<void> {
2782
2784
  await this.#exec.query(
2783
2785
  `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
@@ -2787,8 +2789,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
2787
2789
  cursor=EXCLUDED.cursor,
2788
2790
  subscriptions=EXCLUDED.subscriptions,
2789
2791
  updated_at_ms=EXCLUDED.updated_at_ms,
2790
- last_round_at_ms=EXCLUDED.last_round_at_ms,
2791
- last_error_code=NULL, consecutive_failures=0`,
2792
+ last_round_at_ms=EXCLUDED.last_round_at_ms${
2793
+ options?.keepRoundOutcome === true
2794
+ ? ''
2795
+ : ',\n last_error_code=NULL, consecutive_failures=0'
2796
+ }`,
2792
2797
  [
2793
2798
  partition,
2794
2799
  record.clientId,
@@ -89,6 +89,7 @@ import type {
89
89
  ClientCursorInfo,
90
90
  ClientPushResult,
91
91
  ClientRecord,
92
+ ClientRecordWriteOptions,
92
93
  ClientSubscription,
93
94
  CommitChangeSummary,
94
95
  CommitDetail,
@@ -1892,10 +1893,24 @@ END`);
1892
1893
  async putClientRecord(
1893
1894
  partition: string,
1894
1895
  record: ClientRecord,
1896
+ options?: ClientRecordWriteOptions,
1895
1897
  ): Promise<void> {
1896
1898
  this.db
1897
1899
  .query(
1898
- 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)',
1900
+ `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
1901
+ ON CONFLICT(partition, client_id) DO UPDATE SET
1902
+ actor_id=excluded.actor_id,
1903
+ wire_version=excluded.wire_version,
1904
+ cursor=excluded.cursor,
1905
+ subscriptions=excluded.subscriptions,
1906
+ updated_at_ms=excluded.updated_at_ms,
1907
+ last_round_at_ms=excluded.last_round_at_ms${
1908
+ options?.keepRoundOutcome === true
1909
+ ? ''
1910
+ : `,
1911
+ last_error_code=NULL,
1912
+ consecutive_failures=0`
1913
+ }`,
1899
1914
  )
1900
1915
  .run(
1901
1916
  partition,
package/src/storage.ts CHANGED
@@ -313,6 +313,16 @@ export interface ClientRecord {
313
313
  readonly consecutiveFailures?: number;
314
314
  }
315
315
 
316
+ /** `putClientRecord` options. */
317
+ export interface ClientRecordWriteOptions {
318
+ /**
319
+ * Leave the stored round outcome (`lastErrorCode`, `consecutiveFailures`)
320
+ * untouched. Set for a completed round that carried a `ROUND_FAILURE` report
321
+ * (§1.5, §4.5). `lastRoundAtMs` still moves to this round's completion.
322
+ */
323
+ readonly keepRoundOutcome?: boolean;
324
+ }
325
+
316
326
  /** The round-state fields of a stored `sync_clients` row. */
317
327
  export function clientRoundState(
318
328
  lastRoundAtMs: number | null,
@@ -941,9 +951,15 @@ export interface ServerStorage {
941
951
  ): Promise<ClientRecord | undefined>;
942
952
  /**
943
953
  * Write the record for a completed round. The round-state fields reset: the
944
- * stored last round is a success at `updatedAtMs` with no failures.
954
+ * stored last round is a success at `updatedAtMs` with no failures — unless
955
+ * `keepRoundOutcome` is set, which leaves the reported code and streak in
956
+ * place (the round carried a `ROUND_FAILURE` report, §1.5/§4.5).
945
957
  */
946
- putClientRecord(partition: string, record: ClientRecord): Promise<void>;
958
+ putClientRecord(
959
+ partition: string,
960
+ record: ClientRecord,
961
+ options?: ClientRecordWriteOptions,
962
+ ): Promise<void>;
947
963
  /**
948
964
  * Record a failed round on an existing record: set `lastRoundAtMs` and
949
965
  * `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching