@syncular/server 0.39.2 → 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.
package/src/d1-storage.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { validateCommitPruneQuery } from './prune';
2
2
  import { StorageQueryError } from './storage-errors';
3
3
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
4
- import { PARTITION_TOUCH_INTERVAL_MS } from './storage';
4
+ import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage';
5
5
  /**
6
6
  * Cloudflare D1 server storage for Workers deployments.
7
7
  *
@@ -78,6 +78,7 @@ import {
78
78
  type StoredColumnLayout,
79
79
  scanRowPageSql,
80
80
  schemaDdl,
81
+ serverIndexDdl,
81
82
  SQLITE_TABLE_INDEXES_SQL,
82
83
  type SqliteTableIndexRow,
83
84
  sqliteIndexSignatures,
@@ -104,6 +105,7 @@ import {
104
105
  type SqliteRowRecord,
105
106
  serializePushResult,
106
107
  sqliteDdlStatements,
108
+ SYNC_CLIENT_ROUND_COLUMNS,
107
109
  toStoredChange,
108
110
  toStoredRow,
109
111
  } from './sqlite-dialect';
@@ -114,6 +116,7 @@ import type {
114
116
  ClientCursorInfo,
115
117
  ClientPushResult,
116
118
  ClientRecord,
119
+ ClientRecordWriteOptions,
117
120
  ClientSubscription,
118
121
  CommitChangeSummary,
119
122
  CommitDetail,
@@ -999,6 +1002,15 @@ export class D1ServerStorage implements ServerStorage {
999
1002
  'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;',
1000
1003
  );
1001
1004
  }
1005
+ for (const [name, definition] of Object.entries(
1006
+ SYNC_CLIENT_ROUND_COLUMNS,
1007
+ )) {
1008
+ if (!results.some((column) => column.name === name)) {
1009
+ await this.#db.exec(
1010
+ `ALTER TABLE sync_clients ADD COLUMN ${name} ${definition};`,
1011
+ );
1012
+ }
1013
+ }
1002
1014
  }
1003
1015
 
1004
1016
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
@@ -1129,6 +1141,44 @@ export class D1ServerStorage implements ServerStorage {
1129
1141
  'sqlite',
1130
1142
  );
1131
1143
  }
1144
+ // A database from before the round-state columns gains them in place.
1145
+ statementsExecuted++;
1146
+ const clientColumns = await this.#db
1147
+ .prepare('PRAGMA table_info("sync_clients")')
1148
+ .all<{ name: string }>();
1149
+ const missingRoundColumns = Object.entries(
1150
+ SYNC_CLIENT_ROUND_COLUMNS,
1151
+ ).filter(
1152
+ ([name]) =>
1153
+ !clientColumns.results.some((column) => column.name === name),
1154
+ );
1155
+ if (missingRoundColumns.length > 0) {
1156
+ await batch(
1157
+ missingRoundColumns.map(([name, definition]) =>
1158
+ this.#db.prepare(
1159
+ `ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
1160
+ ),
1161
+ ),
1162
+ );
1163
+ }
1164
+ // Server-only indexes reconcile at every open of a database already at
1165
+ // this version: they never reach a client, so no version bump is needed.
1166
+ const indexes = new Map<string, ReadonlyMap<string, string>>();
1167
+ for (const table of tables) {
1168
+ statementsExecuted++;
1169
+ const { results } = await this.#db
1170
+ .prepare(SQLITE_TABLE_INDEXES_SQL)
1171
+ .bind(table.name)
1172
+ .all<SqliteTableIndexRow>();
1173
+ indexes.set(
1174
+ table.name,
1175
+ new Map(Object.entries(sqliteIndexSignatures(results))),
1176
+ );
1177
+ }
1178
+ const reconcile = serverIndexDdl(schema, indexes);
1179
+ if (reconcile.length > 0) {
1180
+ await batch(reconcile.map((sql) => this.#db.prepare(sql)));
1181
+ }
1132
1182
  this.#tables = schema.tables;
1133
1183
  this.#schemaVersion = schema.version;
1134
1184
  return { complete: true, statementsExecuted };
@@ -1248,19 +1298,20 @@ export class D1ServerStorage implements ServerStorage {
1248
1298
  const columns = await this.#db
1249
1299
  .prepare('PRAGMA table_info("sync_clients")')
1250
1300
  .all<{ name: string }>();
1251
- const needsWireVersion = !columns.results.some(
1252
- (column) => column.name === 'wire_version',
1301
+ const missingColumns = Object.entries({
1302
+ wire_version: 'INTEGER NOT NULL DEFAULT 1',
1303
+ ...SYNC_CLIENT_ROUND_COLUMNS,
1304
+ }).filter(
1305
+ ([name]) => !columns.results.some((column) => column.name === name),
1253
1306
  );
1254
1307
  if (
1255
1308
  !(await save(
1256
1309
  { ...state, phase: 'prepare', offset: 0 },
1257
- needsWireVersion
1258
- ? [
1259
- this.#db.prepare(
1260
- 'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
1261
- ),
1262
- ]
1263
- : [],
1310
+ missingColumns.map(([name, definition]) =>
1311
+ this.#db.prepare(
1312
+ `ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
1313
+ ),
1314
+ ),
1264
1315
  ))
1265
1316
  )
1266
1317
  break;
@@ -2333,7 +2384,7 @@ export class D1ServerStorage implements ServerStorage {
2333
2384
  ): Promise<ClientRecord | undefined> {
2334
2385
  const record = await this.#db
2335
2386
  .prepare(
2336
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
2387
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? AND client_id=?',
2337
2388
  )
2338
2389
  .bind(partition, clientId)
2339
2390
  .first<{
@@ -2343,6 +2394,9 @@ export class D1ServerStorage implements ServerStorage {
2343
2394
  cursor: number;
2344
2395
  subscriptions: string;
2345
2396
  updated_at_ms: number;
2397
+ last_round_at_ms: number | null;
2398
+ last_error_code: string | null;
2399
+ consecutive_failures: number;
2346
2400
  }>();
2347
2401
  if (record === null) return undefined;
2348
2402
  return {
@@ -2352,25 +2406,39 @@ export class D1ServerStorage implements ServerStorage {
2352
2406
  cursor: record.cursor,
2353
2407
  updatedAtMs: record.updated_at_ms,
2354
2408
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
2409
+ ...clientRoundState(
2410
+ record.last_round_at_ms,
2411
+ record.last_error_code,
2412
+ record.consecutive_failures,
2413
+ ),
2355
2414
  };
2356
2415
  }
2357
2416
 
2358
2417
  async putClientRecord(
2359
2418
  partition: string,
2360
2419
  record: ClientRecord,
2420
+ options?: ClientRecordWriteOptions,
2361
2421
  ): Promise<void> {
2362
2422
  // Update the existing record in place, so the table keeps one row per
2363
2423
  // (partition, client_id). Every non-key field is written from the new
2364
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).
2365
2426
  await this.#db
2366
2427
  .prepare(
2367
- `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)
2428
+ `INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
2368
2429
  ON CONFLICT(partition, client_id) DO UPDATE SET
2369
2430
  actor_id=excluded.actor_id,
2370
2431
  wire_version=excluded.wire_version,
2371
2432
  cursor=excluded.cursor,
2372
2433
  subscriptions=excluded.subscriptions,
2373
- updated_at_ms=excluded.updated_at_ms`,
2434
+ updated_at_ms=excluded.updated_at_ms,
2435
+ last_round_at_ms=excluded.last_round_at_ms${
2436
+ options?.keepRoundOutcome === true
2437
+ ? ''
2438
+ : `,
2439
+ last_error_code=NULL,
2440
+ consecutive_failures=0`
2441
+ }`,
2374
2442
  )
2375
2443
  .bind(
2376
2444
  partition,
@@ -2380,7 +2448,26 @@ export class D1ServerStorage implements ServerStorage {
2380
2448
  record.cursor,
2381
2449
  JSON.stringify(record.subscriptions),
2382
2450
  record.updatedAtMs,
2451
+ record.updatedAtMs,
2452
+ )
2453
+ .run();
2454
+ }
2455
+
2456
+ async recordClientRoundFailure(
2457
+ partition: string,
2458
+ clientId: string,
2459
+ actorId: string,
2460
+ errorCode: string,
2461
+ atMs: number,
2462
+ ): Promise<void> {
2463
+ await this.#db
2464
+ .prepare(
2465
+ `UPDATE sync_clients
2466
+ SET last_round_at_ms=?, last_error_code=?,
2467
+ consecutive_failures=consecutive_failures+1
2468
+ WHERE partition=? AND client_id=? AND actor_id=?`,
2383
2469
  )
2470
+ .bind(atMs, errorCode, partition, clientId, actorId)
2384
2471
  .run();
2385
2472
  }
2386
2473
 
@@ -2537,7 +2624,7 @@ export class D1ServerStorage implements ServerStorage {
2537
2624
  async listClientRecords(partition: string): Promise<ClientRecord[]> {
2538
2625
  const { results } = await this.#db
2539
2626
  .prepare(
2540
- 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
2627
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
2541
2628
  )
2542
2629
  .bind(partition)
2543
2630
  .all<{
@@ -2547,6 +2634,9 @@ export class D1ServerStorage implements ServerStorage {
2547
2634
  cursor: number;
2548
2635
  subscriptions: string;
2549
2636
  updated_at_ms: number;
2637
+ last_round_at_ms: number | null;
2638
+ last_error_code: string | null;
2639
+ consecutive_failures: number;
2550
2640
  }>();
2551
2641
  return results.map((record) => ({
2552
2642
  clientId: record.client_id,
@@ -2555,6 +2645,11 @@ export class D1ServerStorage implements ServerStorage {
2555
2645
  cursor: record.cursor,
2556
2646
  updatedAtMs: record.updated_at_ms,
2557
2647
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
2648
+ ...clientRoundState(
2649
+ record.last_round_at_ms,
2650
+ record.last_error_code,
2651
+ record.consecutive_failures,
2652
+ ),
2558
2653
  }));
2559
2654
  }
2560
2655
 
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';
@@ -30,7 +31,7 @@ import {
30
31
  RESOLVER_OUTAGE,
31
32
  touchAuthenticatedPartition,
32
33
  } from './context';
33
- import { SyncError, syncError } from './errors';
34
+ import { reportError, SyncError, syncError } from './errors';
34
35
  import {
35
36
  emitEvent,
36
37
  type PullSubscriptionSummary,
@@ -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(
@@ -734,6 +749,7 @@ async function* streamResponse(
734
749
  report.outcome = 'error';
735
750
  report.errorCode = error.code;
736
751
  }
752
+ await recordRoundFailure(ctx, plan.header.clientId, error);
737
753
  // §1.6: in-band ERROR, then END, nothing else.
738
754
  yield encodeResponseFrame(
739
755
  {
@@ -765,7 +781,14 @@ export async function createSyncResponseStream(
765
781
  ctx: SyncRequestContext,
766
782
  ): Promise<AsyncIterable<Uint8Array>> {
767
783
  const sink = ctx.events;
768
- if (sink === undefined) return createStreamCore(bytes, ctx, undefined);
784
+ if (sink === undefined) {
785
+ try {
786
+ return await createStreamCore(bytes, ctx, undefined);
787
+ } catch (error) {
788
+ await recordRoundFailure(ctx, requestClientId(bytes), error);
789
+ throw error;
790
+ }
791
+ }
769
792
  // Instrumented path: measure with the ctx clock, count bytes at the
770
793
  // seam, emit exactly one `request.handled` per request. Every event of
771
794
  // this request carries one `requestId`.
@@ -779,6 +802,7 @@ export async function createSyncResponseStream(
779
802
  try {
780
803
  return await createStreamCore(bytes, requestCtx, events, startedAtMs);
781
804
  } catch (error) {
805
+ const clientId = requestClientId(bytes);
782
806
  emitEvent(events, {
783
807
  type: 'request.handled',
784
808
  kind: 'sync',
@@ -789,11 +813,78 @@ export async function createSyncResponseStream(
789
813
  bytesIn: bytes.length,
790
814
  bytesOut: 0,
791
815
  outcome: 'rejected',
816
+ ...(clientId !== undefined ? { clientId } : {}),
792
817
  errorCode: error instanceof SyncError ? error.code : 'internal',
793
818
  pushCommits: 0,
794
819
  pulled: false,
795
820
  subscriptions: 0,
796
821
  });
822
+ await recordRoundFailure(ctx, clientId, error);
823
+ throw error;
824
+ }
825
+ }
826
+
827
+ /** The `REQ_HEADER` clientId of a request that failed before planning. */
828
+ function requestClientId(bytes: Uint8Array): string | undefined {
829
+ try {
830
+ const message = decodeMessage(bytes);
831
+ const header = message.frames[0];
832
+ return message.msgKind === 'request' && header?.type === 'REQ_HEADER'
833
+ ? header.clientId
834
+ : undefined;
835
+ } catch {
836
+ return undefined;
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Record a failed round on the client's stored record so an operator can see
842
+ * a stuck client and why. A storage failure here is reported through
843
+ * `onError` and never replaces the round's own outcome.
844
+ */
845
+ async function recordRoundFailure(
846
+ ctx: SyncRequestContext,
847
+ clientId: string | undefined,
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,
863
+ ): Promise<void> {
864
+ if (clientId === undefined) return;
865
+ try {
866
+ await ctx.storage.recordClientRoundFailure(
867
+ ctx.partition,
868
+ clientId,
869
+ ctx.actorId,
870
+ code,
871
+ clockOf(ctx)(),
872
+ );
873
+ } catch (failure) {
874
+ reportError(failure, ctx.onError, 'sync');
875
+ }
876
+ }
877
+
878
+ /** Record a failure thrown while the response streams (after planning). */
879
+ async function* recordedStream(
880
+ stream: AsyncIterable<Uint8Array>,
881
+ ctx: SyncRequestContext,
882
+ clientId: string,
883
+ ): AsyncGenerator<Uint8Array> {
884
+ try {
885
+ yield* stream;
886
+ } catch (error) {
887
+ await recordRoundFailure(ctx, clientId, error);
797
888
  throw error;
798
889
  }
799
890
  }
@@ -855,6 +946,16 @@ async function createStreamCore(
855
946
  );
856
947
  const served = selected ?? schema;
857
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
+ }
858
959
  const report: RequestReport = { outcome: 'ok' };
859
960
  // RFC 0007 read-verify-refuse for the streamed read path: the generator
860
961
  // builds the whole response (its data reads) before yielding the first
@@ -883,15 +984,21 @@ async function createStreamCore(
883
984
  if (changed !== undefined) throw serveNotReadyError(changed);
884
985
  for (const chunk of buffered) yield chunk;
885
986
  };
886
- if (events === undefined) return verified();
887
- return instrumentedStream(
888
- verified(),
987
+ if (events === undefined) {
988
+ return recordedStream(verified(), ctx, plan.header.clientId);
989
+ }
990
+ return recordedStream(
991
+ instrumentedStream(
992
+ verified(),
993
+ ctx,
994
+ events,
995
+ plan,
996
+ report,
997
+ bytes.length,
998
+ startedAtMs,
999
+ ),
889
1000
  ctx,
890
- events,
891
- plan,
892
- report,
893
- bytes.length,
894
- startedAtMs,
1001
+ plan.header.clientId,
895
1002
  );
896
1003
  }
897
1004