@synnaxlabs/client 0.57.1 → 0.57.3

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.
Files changed (53) hide show
  1. package/dist/{client-DDACDVZ-.js → client-CX6CMvCc.js} +3686 -3548
  2. package/dist/client-CX6CMvCc.js.map +1 -0
  3. package/dist/client.js +1 -1
  4. package/dist/src/arc/client.d.ts.map +1 -1
  5. package/dist/src/channel/client.d.ts +1 -0
  6. package/dist/src/channel/client.d.ts.map +1 -1
  7. package/dist/src/device/client.d.ts.map +1 -1
  8. package/dist/src/framer/streamer.d.ts +34 -18
  9. package/dist/src/framer/streamer.d.ts.map +1 -1
  10. package/dist/src/label/store.d.ts +1 -1
  11. package/dist/src/label/store.d.ts.map +1 -1
  12. package/dist/src/ontology/client.d.ts.map +1 -1
  13. package/dist/src/ontology/store.d.ts +16 -1
  14. package/dist/src/ontology/store.d.ts.map +1 -1
  15. package/dist/src/query/external.d.ts +1 -0
  16. package/dist/src/query/external.d.ts.map +1 -1
  17. package/dist/src/query/indexes.d.ts +38 -0
  18. package/dist/src/query/indexes.d.ts.map +1 -0
  19. package/dist/src/query/table.d.ts +20 -4
  20. package/dist/src/query/table.d.ts.map +1 -1
  21. package/dist/src/rack/client.d.ts.map +1 -1
  22. package/dist/src/ranger/client.d.ts +11 -1
  23. package/dist/src/ranger/client.d.ts.map +1 -1
  24. package/dist/src/status/client.d.ts.map +1 -1
  25. package/dist/src/status/status.d.ts +2 -0
  26. package/dist/src/status/status.d.ts.map +1 -1
  27. package/dist/src/task/client.d.ts +19 -1
  28. package/dist/src/task/client.d.ts.map +1 -1
  29. package/dist/src/testutil/client.d.ts +2 -0
  30. package/dist/src/testutil/client.d.ts.map +1 -1
  31. package/dist/src/testutil/proxy.d.ts +8 -0
  32. package/dist/src/testutil/proxy.d.ts.map +1 -1
  33. package/dist/testutil.js +80 -60
  34. package/dist/testutil.js.map +1 -1
  35. package/package.json +5 -5
  36. package/src/arc/client.ts +2 -20
  37. package/src/channel/client.ts +11 -6
  38. package/src/device/client.ts +4 -3
  39. package/src/framer/streamer.ts +71 -6
  40. package/src/label/store.ts +7 -2
  41. package/src/ontology/client.ts +15 -8
  42. package/src/ontology/store.ts +27 -5
  43. package/src/query/external.ts +1 -0
  44. package/src/query/indexes.ts +94 -0
  45. package/src/query/table.ts +74 -12
  46. package/src/rack/client.ts +5 -4
  47. package/src/ranger/client.ts +65 -24
  48. package/src/status/client.ts +15 -11
  49. package/src/status/status.ts +14 -0
  50. package/src/task/client.ts +54 -19
  51. package/src/testutil/client.ts +5 -1
  52. package/src/testutil/proxy.ts +59 -6
  53. package/dist/client-DDACDVZ-.js.map +0 -1
@@ -288,6 +288,21 @@ const retrieveMultiParamsZ = retrieveRequestZ
288
288
 
289
289
  const retrieveResZ = z.object({ ranges: payloadZ.array().default(() => []) });
290
290
 
291
+ /** Request addressing a page of a range's children, in relationship-key order. */
292
+ export type ChildrenRequest = {
293
+ key: Key;
294
+ limit?: number;
295
+ offset?: number;
296
+ };
297
+
298
+ /** Params addressing a range's children. A bare key addresses all of them. */
299
+ export type ChildrenParams = Key | ChildrenRequest;
300
+
301
+ const CHILDREN_SERVER_FIELDS = ["limit", "offset"] as const;
302
+
303
+ const normalizeChildren = (params: ChildrenParams): ChildrenRequest =>
304
+ typeof params === "string" ? { key: params } : params;
305
+
291
306
  /** The base flags applied to every composed range fetch. */
292
307
  const BASE_REQUEST: Partial<RetrieveRequest> = {
293
308
  includeLabels: true,
@@ -340,16 +355,13 @@ const watchRelationships = <Q extends query.Params>(
340
355
  affectedRangeKeys(relOfEvent(event)),
341
356
  );
342
357
 
343
- const rangesWithLabel = (
344
- relationships: query.Table<string, ontology.Relationship>,
345
- key: label.Key,
346
- ): Key[] | null => {
347
- const keys = relationships
348
- .get(
358
+ const rangesWithLabel = (cache: ontology.Cache, key: label.Key): Key[] | null => {
359
+ const keys = cache
360
+ .relationshipsTo(label.ontologyID(key))
361
+ .filter(
349
362
  (r) =>
350
363
  r.type === label.LABELED_BY_ONTOLOGY_RELATIONSHIP_TYPE &&
351
- r.from.type === "range" &&
352
- r.to.key === key,
364
+ r.from.type === "range",
353
365
  )
354
366
  .map((r) => r.from.key);
355
367
  return keys.length === 0 ? null : keys;
@@ -358,10 +370,10 @@ const rangesWithLabel = (
358
370
  /** Projects label content changes onto the ranges they label. */
359
371
  const watchLabels = <Q extends query.Params>(
360
372
  labels: query.Table<label.Key, label.Label>,
361
- relationships: query.Table<string, ontology.Relationship>,
373
+ cache: ontology.Cache,
362
374
  ): query.Watch<Q, Key> =>
363
375
  query.watch<Q, Key, label.Key, label.Label>(labels, (event) =>
364
- rangesWithLabel(relationships, event.key),
376
+ rangesWithLabel(cache, event.key),
365
377
  );
366
378
 
367
379
  /** Config for {@link Client}. */
@@ -389,7 +401,7 @@ export class Client extends query.Retriever<
389
401
  /** The range alias table; injected into sibling clients at wiring. */
390
402
  readonly aliases: query.Table<string, alias.Alias>;
391
403
  /** Cached queries for the children of a range, keyed by the parent's key. */
392
- readonly children: query.Retrieves<Key, Range[]>;
404
+ readonly children: query.Retrieves<ChildrenParams, Range[]>;
393
405
  /**
394
406
  * Cached queries for the closest range parent of a resource, keyed by the
395
407
  * child's ontology ID.
@@ -422,7 +434,9 @@ export class Client extends query.Retriever<
422
434
  query.deriveWatch(relationships, (event) =>
423
435
  affectedRangeKeys(relOfEvent(event)),
424
436
  ),
425
- query.deriveWatch(labels, (event) => rangesWithLabel(relationships, event.key)),
437
+ query.deriveWatch(labels, (event) =>
438
+ rangesWithLabel(ontologyClient.cache, event.key),
439
+ ),
426
440
  ],
427
441
  });
428
442
  const single = cache.queries<Key | Name, Range, Key, Range>({
@@ -443,7 +457,7 @@ export class Client extends query.Retriever<
443
457
  matches: (r, query) => this.requestMatches(r, query),
444
458
  watch: [
445
459
  watchRelationships<RetrieveRequest>(relationships),
446
- watchLabels<RetrieveRequest>(labels, relationships),
460
+ watchLabels<RetrieveRequest>(labels, ontologyClient.cache),
447
461
  ],
448
462
  },
449
463
  compose: (r) => this.composeOne(r),
@@ -454,17 +468,27 @@ export class Client extends query.Retriever<
454
468
  this.store = ranges;
455
469
  this.kvPairs = kvPairs;
456
470
  this.aliases = aliases;
457
- this.children = cache.queries<Key, Range[], Key, Range>({
471
+ const children = cache.queries<ChildrenRequest, Range[], Key, Range>({
458
472
  name: "child ranges",
459
473
  table: composed,
460
474
  fetch: async (query) => (await this.fetchChildren(query)).map((r) => r.key),
461
475
  compose: (records) => records,
462
476
  matches: (r, query) => {
463
477
  const parent = this.cfg.ontology.cache.parentID(ontologyID(r.key));
464
- return parent != null && ontology.idsEqual(parent, ontologyID(query));
478
+ return parent != null && ontology.idsEqual(parent, ontologyID(query.key));
465
479
  },
466
- watch: [watchRelationships<Key>(this.cfg.ontology.cache.relationships)],
480
+ serverFields: CHILDREN_SERVER_FIELDS,
481
+ watch: [
482
+ watchRelationships<ChildrenRequest>(this.cfg.ontology.cache.relationships),
483
+ ],
467
484
  });
485
+ this.children = {
486
+ retrieve: async (params, options) =>
487
+ await children.retrieve(normalizeChildren(params), options),
488
+ onChange: (params, handler) =>
489
+ children.onChange(normalizeChildren(params), handler),
490
+ getCached: (params) => children.getCached(normalizeChildren(params)),
491
+ };
468
492
  this.parent = cache.queries<ontology.ID, Range | null, Key, Range>({
469
493
  name: "parent range",
470
494
  table: composed,
@@ -597,7 +621,7 @@ export class Client extends query.Retriever<
597
621
  private composeOne(cached: Range): Range {
598
622
  const id = ontologyID(cached.key);
599
623
  const labels = label.cachedLabelsOf(
600
- this.cfg.ontology.cache.relationships,
624
+ this.cfg.ontology.cache,
601
625
  this.cfg.labels.store,
602
626
  id,
603
627
  );
@@ -642,6 +666,17 @@ export class Client extends query.Retriever<
642
666
  }
643
667
  }
644
668
 
669
+ /** Writes a fetch response as one batch per table, so each table flushes once. */
670
+ private writeThroughMany(ranges: Range[]): void {
671
+ this.store.batch(() =>
672
+ this.cfg.labels.store.batch(() =>
673
+ this.cfg.ontology.cache.relationships.batch(() =>
674
+ ranges.forEach((r) => this.writeThrough(r)),
675
+ ),
676
+ ),
677
+ );
678
+ }
679
+
645
680
  /**
646
681
  * Fetches the given keys with labels and parents included, writing them and
647
682
  * their relationships through. Powers the table's fetch primitive.
@@ -652,15 +687,19 @@ export class Client extends query.Retriever<
652
687
  keys,
653
688
  ignoreNotFoundError: true,
654
689
  });
655
- ranges.forEach((r) => this.writeThrough(r));
690
+ this.writeThroughMany(ranges);
656
691
  return ranges;
657
692
  }
658
693
 
659
694
  private async fetchSingle(query: Key | Name): Promise<Range> {
660
695
  const cached = this.store.get(query);
661
696
  if (cached != null) return this.composeOne(cached);
662
- const req = keyZ.safeParse(query).success ? { keys: [query] } : { names: [query] };
663
- const ranges = await this.execRetrieve({ ...BASE_REQUEST, ...req });
697
+ if (keyZ.safeParse(query).success) {
698
+ const ranges = await this.store.retrieve([query]);
699
+ checkForMultipleOrNoResults("Range", query, ranges, true);
700
+ return this.composeOne(ranges[0]);
701
+ }
702
+ const ranges = await this.execRetrieve({ ...BASE_REQUEST, names: [query] });
664
703
  checkForMultipleOrNoResults("Range", query, ranges, true);
665
704
  this.writeThrough(ranges[0]);
666
705
  return ranges[0];
@@ -686,7 +725,7 @@ export class Client extends query.Retriever<
686
725
  private async fetchRequest(query: RetrieveRequest): Promise<Range[]> {
687
726
  if (isKeysOnly(query)) return await this.store.retrieve(query.keys);
688
727
  const ranges = await this.execRetrieve({ ...BASE_REQUEST, ...query });
689
- ranges.forEach((r) => this.writeThrough(r));
728
+ this.writeThroughMany(ranges);
690
729
  return ranges;
691
730
  }
692
731
 
@@ -701,7 +740,7 @@ export class Client extends query.Retriever<
701
740
  return false;
702
741
  if (primitive.isNonZero(req.hasLabels)) {
703
742
  const labels = label.cachedLabelsOf(
704
- this.cfg.ontology.cache.relationships,
743
+ this.cfg.ontology.cache,
705
744
  this.cfg.labels.store,
706
745
  ontologyID(r.key),
707
746
  );
@@ -711,10 +750,12 @@ export class Client extends query.Retriever<
711
750
  return true;
712
751
  }
713
752
 
714
- private async fetchChildren(query: Key): Promise<Range[]> {
753
+ private async fetchChildren(query: ChildrenRequest): Promise<Range[]> {
715
754
  const resources = await this.cfg.ontology.children.retrieve({
716
- ids: ontologyID(query),
755
+ ids: ontologyID(query.key),
717
756
  types: ["range"],
757
+ limit: query.limit,
758
+ offset: query.offset,
718
759
  });
719
760
  if (resources.length === 0) return [];
720
761
  return await this.store.retrieve(resources.map(({ id: { key } }) => key));
@@ -99,7 +99,7 @@ export class Client extends query.Retriever<
99
99
  value: (changed, prev) => {
100
100
  const next = { ...prev, ...changed };
101
101
  const id = ontologyID(changed.key);
102
- next.labels = label.cachedLabelsOf(relationships, labels.store, id);
102
+ next.labels = label.cachedLabelsOf(ontologyClient.cache, labels.store, id);
103
103
  return next;
104
104
  },
105
105
  }),
@@ -278,7 +278,7 @@ export class Client extends query.Retriever<
278
278
  /** Rebuilds a cached status with its cached labels attached. */
279
279
  private compose(cached: Status): Status {
280
280
  const labels = label.cachedLabelsOf(
281
- this.cfg.ontology.cache.relationships,
281
+ this.cfg.ontology.cache,
282
282
  this.cfg.labels.store,
283
283
  ontologyID(cached.key),
284
284
  );
@@ -307,14 +307,19 @@ export class Client extends query.Retriever<
307
307
  /** Fetches statuses and writes their included labels through the caches. */
308
308
  private async fetchThrough(req: RetrieveRequest): Promise<Status[]> {
309
309
  const statuses = await this.execRetrieve({ ...BASE_REQUEST, ...req });
310
- statuses.forEach((s) => this.writeThrough(s));
310
+ // One batch per table, so each table flushes once for the whole response.
311
+ this.store.batch(() =>
312
+ this.cfg.labels.store.batch(() =>
313
+ this.cfg.ontology.cache.relationships.batch(() =>
314
+ statuses.forEach((s) => this.writeThrough(s)),
315
+ ),
316
+ ),
317
+ );
311
318
  return statuses;
312
319
  }
313
320
 
314
321
  private async fetchSingle(key: Key): Promise<Status> {
315
- const cached = this.store.get(key);
316
- if (cached != null) return cached;
317
- const statuses = await this.fetchThrough({ keys: [key] });
322
+ const statuses = await this.store.retrieve([key]);
318
323
  checkForMultipleOrNoResults("Status", key, statuses, true);
319
324
  return statuses[0];
320
325
  }
@@ -362,13 +367,12 @@ export class Client extends query.Retriever<
362
367
 
363
368
  /** Returns the keys of cached statuses labeled by the given label. */
364
369
  private statusesLabeledBy(labelKey: label.Key): Key[] {
365
- return this.cfg.ontology.cache.relationships
366
- .get(
370
+ return this.cfg.ontology.cache
371
+ .relationshipsTo(label.ontologyID(labelKey))
372
+ .filter(
367
373
  (r) =>
368
374
  r.type === label.LABELED_BY_ONTOLOGY_RELATIONSHIP_TYPE &&
369
- r.from.type === "status" &&
370
- r.to.type === "label" &&
371
- r.to.key === labelKey,
375
+ r.from.type === "status",
372
376
  )
373
377
  .map((r) => r.from.key);
374
378
  }
@@ -192,6 +192,20 @@ export const removeVariants = (
192
192
  return remove === variant ? undefined : variant;
193
193
  };
194
194
 
195
+ /** Rank of each variant by severity, most severe lowest. */
196
+ const SEVERITY: Record<Variant, number> = {
197
+ error: 0,
198
+ warning: 1,
199
+ loading: 2,
200
+ success: 3,
201
+ info: 4,
202
+ disabled: 5,
203
+ };
204
+
205
+ /** @returns true when a is more severe than b. */
206
+ export const moreSevere = (a: Variant, b: Variant): boolean =>
207
+ SEVERITY[a] < SEVERITY[b];
208
+
195
209
  /** Options for {@link toString}. */
196
210
  export interface ToStringOptions {
197
211
  includeTimestamp?: boolean;
@@ -91,6 +91,12 @@ const COMMAND_DEADLINE = TimeSpan.seconds(10);
91
91
 
92
92
  const STATUS_NAME = "Task Status";
93
93
 
94
+ /**
95
+ * The task status schema without a details-data schema. Hoisted because statusZ()
96
+ * builds a fresh schema per call, which costs far more than the parse itself.
97
+ */
98
+ export const defaultStatusZ = statusZ();
99
+
94
100
  const retrieveSnapshottedTo = async (taskKey: Key, ontologyClient: ontology.Client) => {
95
101
  const parents = await ontologyClient.parents.retrieve({ ids: ontologyID(taskKey) });
96
102
  if (parents.length === 0) return null;
@@ -415,10 +421,14 @@ export class Client extends query.Retriever<
415
421
  /** The task record table; injected into sibling clients at wiring. */
416
422
  readonly store: query.Table<Key, Omit<Task, "status">>;
417
423
  private readonly cfg: ClientConfig;
424
+ private readonly statusesByTask: query.LookupIndex<status.Key, status.Status>;
418
425
  private readonly commandDeadlines = new Map<Key, ReturnType<typeof setTimeout>>();
419
426
 
420
427
  constructor(cfg: ClientConfig) {
421
428
  const { cache, statusStore } = cfg;
429
+ const statusesByTask = statusStore.index(
430
+ new query.LookupIndex<status.Key, status.Status>(statusTaskKey),
431
+ );
422
432
  const store = cache.createTable<Key, Omit<Task, "status">>({
423
433
  name: "tasks",
424
434
  equal: (a, b) => deep.equal(a.payload, b.payload),
@@ -458,7 +468,9 @@ export class Client extends query.Retriever<
458
468
  commands.forEach((changed) => {
459
469
  if (!LOADING_COMMANDS.includes(changed.type)) return;
460
470
  const key = statusKey(changed.task);
461
- if (statusStore.get(key) == null) return;
471
+ // Commands reach every client, so one for a task this client neither
472
+ // tracks nor holds a status for is dropped.
473
+ if (store.get(changed.task) == null && statusStore.get(key) == null) return;
462
474
  const optimistic = this.optimisticStatus(changed);
463
475
  statusStore.set(key, optimistic);
464
476
  if (optimistic.variant === "loading")
@@ -471,7 +483,7 @@ export class Client extends query.Retriever<
471
483
  source: store,
472
484
  compose: (record) => this.compose(record),
473
485
  equal: (a, b) => deep.equal(a.payload, b.payload),
474
- watch: [query.deriveWatch(statusStore, (event) => affectedTaskKeys(event))],
486
+ watch: [query.deriveWatch(statusStore, (event) => affectedKeys(event))],
475
487
  });
476
488
  const single = cache.queries<SingleRequest, Task, Key, Task>({
477
489
  name: "task",
@@ -489,13 +501,14 @@ export class Client extends query.Retriever<
489
501
  schema: retrieveMultiParamsZ,
490
502
  fetch: async (req) => await this.fetchThrough(req),
491
503
  matches: (t, req) => requestFilter(req)(t),
492
- watch: [query.watch(statusStore, (event) => affectedTaskKeys(event))],
504
+ watch: [query.watch(statusStore, (event) => affectedKeys(event))],
493
505
  },
494
506
  compose: (record) => this.compose(record),
495
507
  single: { schema: singleQueryZ, space: single },
496
508
  });
497
509
  this.cfg = cfg;
498
510
  this.store = store;
511
+ this.statusesByTask = statusesByTask;
499
512
  }
500
513
 
501
514
  async create(task: New): Promise<Task>;
@@ -719,20 +732,25 @@ export class Client extends query.Retriever<
719
732
  );
720
733
  }
721
734
 
722
- // A task's status may live under the "task:<key>" row or under any status
723
- // whose details reference the task; the freshest wins. Rows are parsed
724
- // because the status table holds every domain's statuses generically.
735
+ // A task's status may live under the "task:<key>" row or under any status whose
736
+ // details reference the task; the freshest wins, ties going to the more severe. Rows
737
+ // are parsed because the status table holds every domain's statuses generically.
725
738
  private latestStatusOf(key: Key): Status | undefined {
726
- const taskKey = statusKey(key);
727
- const candidates = this.cfg.statusStore
728
- .get((s) => s.key === taskKey || status.detailsOf(s)?.task === key)
729
- .map((s) => statusZ().safeParse(s))
739
+ const direct = this.cfg.statusStore.get(statusKey(key));
740
+ const rows = this.statusesByTask.get(key);
741
+ // A task status names its task in details too, so the index usually holds it.
742
+ if (direct != null && !rows.includes(direct)) rows.push(direct);
743
+ const candidates = rows
744
+ .map((s) => defaultStatusZ.safeParse(s))
730
745
  .filter((p) => p.success)
731
746
  .map((p) => p.data);
732
747
  if (candidates.length === 0) return undefined;
733
- return candidates.reduce((latest, s) =>
734
- new TimeStamp(s.time).afterEq(new TimeStamp(latest.time)) ? s : latest,
735
- );
748
+ return candidates.reduce((latest, s) => {
749
+ const time = new TimeStamp(s.time);
750
+ const latestTime = new TimeStamp(latest.time);
751
+ if (!time.equals(latestTime)) return time.after(latestTime) ? s : latest;
752
+ return status.moreSevere(s.variant, latest.variant) ? s : latest;
753
+ });
736
754
  }
737
755
 
738
756
  /** Writes a fetched task and its included status. */
@@ -759,6 +777,12 @@ export class Client extends query.Retriever<
759
777
  // no status or the status may not have synced. Only both count as a hit.
760
778
  if (cached != null && this.cfg.statusStore.has(statusKey(cached.key)))
761
779
  return this.compose(cached);
780
+ if (primitive.isNonZero(q.keys)) {
781
+ // The refresh covers a cached task whose status has not synced.
782
+ const tasks = await this.store.retrieve(q.keys, { refresh: true });
783
+ checkForMultipleOrNoResults("Task", q, tasks, true);
784
+ return this.compose(tasks[0]);
785
+ }
762
786
  const tasks = await this.execRetrieve({ ...q, includeStatus: true });
763
787
  checkForMultipleOrNoResults("Task", q, tasks, true);
764
788
  this.writeThrough(tasks[0]);
@@ -866,17 +890,28 @@ export class Client extends query.Retriever<
866
890
  /** @returns the key of the status that reports on the given task. */
867
891
  export const statusKey = (key: Key): string => ontology.idToString(ontologyID(key));
868
892
 
869
- const taskStatusZ = z.object({ details: z.object({ task: keyZ }) });
893
+ /**
894
+ * @returns the key of the task the status reports on, or null when it names none.
895
+ * Guarded before the key parse: most statuses in the table belong to other domains,
896
+ * and a failed zod parse costs orders of magnitude more than the miss is worth.
897
+ */
898
+ const statusTaskKey = (s: status.Status): Key | null => {
899
+ const task = status.detailsOf(s)?.task;
900
+ if (typeof task !== "string") return null;
901
+ return keyZ.safeParse(task).success ? task : null;
902
+ };
870
903
 
871
- // Task statuses may arrive under any status key; the referenced task lives in
872
- // the details, with the "task:<key>" status key as a fallback.
873
- const affectedTaskKeys = (
904
+ /**
905
+ * @returns the keys of the tasks a status change affects, or null when none. A status
906
+ * names its task in `details.task`, with the "task:<key>" status key as a fallback.
907
+ */
908
+ export const affectedKeys = (
874
909
  event: query.TableEvent<status.Key, status.Status>,
875
910
  ): Key[] | null => {
876
911
  const keys: Key[] = [];
877
912
  if (event.variant === "set") {
878
- const parsed = taskStatusZ.safeParse(event.value);
879
- if (parsed.success) keys.push(parsed.data.details.task);
913
+ const task = statusTaskKey(event.value);
914
+ if (task != null) keys.push(task);
880
915
  }
881
916
  const [type, key] = event.key.split(":");
882
917
  if (type === "task" && primitive.isNonZero(key) && !keys.includes(key))
@@ -8,7 +8,7 @@
8
8
  // included in the file licenses/APL.txt.
9
9
 
10
10
  import { TimeSpan } from "@synnaxlabs/x";
11
- import { afterAll } from "vitest";
11
+ import { afterAll, vi } from "vitest";
12
12
 
13
13
  import Synnax, { type SynnaxParams } from "@/client";
14
14
 
@@ -50,3 +50,7 @@ export const createTestClient = (params?: Partial<SynnaxParams>): Synnax => {
50
50
  openClients.push(client);
51
51
  return client;
52
52
  };
53
+
54
+ /** Spies on the client's unary send so specs can count requests on the wire. */
55
+ export const spyOnSend = (client: Synnax, field: "transport" = "transport") =>
56
+ vi.spyOn(client[field].unary, "send");
@@ -35,12 +35,27 @@ export interface SeverableProxy {
35
35
  port: number;
36
36
  /** Drops every live connection and refuses new ones, as if the cluster died. */
37
37
  sever: () => Promise<void>;
38
+ /**
39
+ * Silently kills every live WebSocket connection, modeling a half-open socket (NAT
40
+ * timeout, VPN drop, laptop sleep): the cluster side is destroyed while the client
41
+ * side is held open with its writes discarded, so no close or reset ever reaches the
42
+ * client. New connections still forward. Returns how many connections were
43
+ * blackholed.
44
+ */
45
+ blackholeStreams: () => number;
38
46
  /** Accepts connections again on the same port. */
39
47
  restore: () => Promise<void>;
40
48
  /** Shuts the proxy down for good. */
41
49
  close: () => Promise<void>;
42
50
  }
43
51
 
52
+ interface ProxiedPair {
53
+ downstream: Socket;
54
+ upstream: Socket;
55
+ /** Whether a WebSocket upgrade was observed on this connection. */
56
+ ws: boolean;
57
+ }
58
+
44
59
  /**
45
60
  * Creates a TCP proxy in front of the live test cluster so specs can simulate
46
61
  * cluster downtime without touching the cluster itself. Connect a client to
@@ -50,7 +65,10 @@ export const createSeverableProxy = async (
50
65
  target: SeverableProxyTarget = {},
51
66
  ): Promise<SeverableProxy> => {
52
67
  const { host, port } = { ...DEFAULT_TARGET, ...target };
53
- const sockets = new Set<Socket>();
68
+ const pairs = new Set<ProxiedPair>();
69
+ // Client-side sockets of blackholed connections: held open, silent, until the proxy
70
+ // is severed or closed.
71
+ const zombies = new Set<Socket>();
54
72
  let severed = false;
55
73
  const server = createServer((downstream) => {
56
74
  // A connection the kernel accepted before the sever is still delivered after it,
@@ -60,10 +78,16 @@ export const createSeverableProxy = async (
60
78
  return;
61
79
  }
62
80
  const upstream = connect(port, host);
63
- sockets.add(downstream).add(upstream);
81
+ const pair: ProxiedPair = { downstream, upstream, ws: false };
82
+ pairs.add(pair);
83
+ // The upgrade can arrive on any request of a pooled keep-alive connection, not just
84
+ // the first, so every chunk is sniffed until one is seen.
85
+ downstream.on("data", (chunk: Buffer) => {
86
+ if (!pair.ws && /upgrade:\s*websocket/i.test(chunk.toString("latin1")))
87
+ pair.ws = true;
88
+ });
64
89
  const destroy = (): void => {
65
- sockets.delete(downstream);
66
- sockets.delete(upstream);
90
+ pairs.delete(pair);
67
91
  downstream.destroy();
68
92
  upstream.destroy();
69
93
  };
@@ -90,13 +114,42 @@ export const createSeverableProxy = async (
90
114
  const sever = async (): Promise<void> => {
91
115
  severed = true;
92
116
  const closed = new Promise<void>((resolve) => server.close(() => resolve()));
93
- sockets.forEach((socket) => socket.destroy());
94
- sockets.clear();
117
+ pairs.forEach(({ downstream, upstream }) => {
118
+ downstream.destroy();
119
+ upstream.destroy();
120
+ });
121
+ pairs.clear();
122
+ zombies.forEach((socket) => socket.destroy());
123
+ zombies.clear();
95
124
  await closed;
96
125
  };
126
+ const blackholeStreams = (): number => {
127
+ let count = 0;
128
+ for (const pair of [...pairs]) {
129
+ if (!pair.ws) continue;
130
+ count += 1;
131
+ const { downstream, upstream } = pair;
132
+ downstream.unpipe(upstream);
133
+ upstream.unpipe(downstream);
134
+ for (const socket of [downstream, upstream]) {
135
+ socket.removeAllListeners("close");
136
+ socket.removeAllListeners("error");
137
+ socket.on("error", () => {});
138
+ }
139
+ // Discard anything the client still writes so backpressure cannot close the
140
+ // socket on the proxy's behalf.
141
+ downstream.on("data", () => {});
142
+ downstream.resume();
143
+ upstream.destroy();
144
+ pairs.delete(pair);
145
+ zombies.add(downstream);
146
+ }
147
+ return count;
148
+ };
97
149
  return {
98
150
  port: boundPort,
99
151
  sever,
152
+ blackholeStreams,
100
153
  restore: async () => {
101
154
  severed = false;
102
155
  await listen(boundPort);