@synnaxlabs/client 0.57.2 → 0.58.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.
Files changed (83) hide show
  1. package/dist/{client-BsVW3qAW.js → client-0I0a7Hkw.js} +10121 -9064
  2. package/dist/client-0I0a7Hkw.js.map +1 -0
  3. package/dist/client.js +2061 -978
  4. package/dist/client.js.map +1 -1
  5. package/dist/src/access/policy/client.d.ts +1 -2
  6. package/dist/src/access/policy/client.d.ts.map +1 -1
  7. package/dist/src/arc/client.d.ts.map +1 -1
  8. package/dist/src/channel/client.d.ts +2 -1
  9. package/dist/src/channel/client.d.ts.map +1 -1
  10. package/dist/src/device/client.d.ts +0 -2
  11. package/dist/src/device/client.d.ts.map +1 -1
  12. package/dist/src/framer/cache/cache.d.ts.map +1 -1
  13. package/dist/src/framer/cache/reader.d.ts.map +1 -1
  14. package/dist/src/framer/cache/static.d.ts +14 -0
  15. package/dist/src/framer/cache/static.d.ts.map +1 -1
  16. package/dist/src/framer/cache/unary.d.ts +3 -0
  17. package/dist/src/framer/cache/unary.d.ts.map +1 -1
  18. package/dist/src/framer/feed.d.ts.map +1 -1
  19. package/dist/src/framer/streamer.d.ts +34 -18
  20. package/dist/src/framer/streamer.d.ts.map +1 -1
  21. package/dist/src/framer/writer.d.ts.map +1 -1
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/lineplot/actions.d.ts +48 -0
  24. package/dist/src/lineplot/actions.d.ts.map +1 -1
  25. package/dist/src/lineplot/actions.gen.d.ts +237 -0
  26. package/dist/src/lineplot/actions.gen.d.ts.map +1 -1
  27. package/dist/src/lineplot/types.gen.d.ts +87 -7
  28. package/dist/src/lineplot/types.gen.d.ts.map +1 -1
  29. package/dist/src/panel/client.d.ts +1 -2
  30. package/dist/src/panel/client.d.ts.map +1 -1
  31. package/dist/src/project/client.d.ts +1 -2
  32. package/dist/src/project/client.d.ts.map +1 -1
  33. package/dist/src/query/indexes.d.ts +11 -6
  34. package/dist/src/query/indexes.d.ts.map +1 -1
  35. package/dist/src/query/table.d.ts +12 -6
  36. package/dist/src/query/table.d.ts.map +1 -1
  37. package/dist/src/rack/client.d.ts.map +1 -1
  38. package/dist/src/ranger/client.d.ts +10 -4
  39. package/dist/src/ranger/client.d.ts.map +1 -1
  40. package/dist/src/schematic/actions.d.ts.map +1 -1
  41. package/dist/src/status/client.d.ts.map +1 -1
  42. package/dist/src/status/status.d.ts +2 -0
  43. package/dist/src/status/status.d.ts.map +1 -1
  44. package/dist/src/task/client.d.ts +19 -1
  45. package/dist/src/task/client.d.ts.map +1 -1
  46. package/dist/src/testutil/client.d.ts +2 -0
  47. package/dist/src/testutil/client.d.ts.map +1 -1
  48. package/dist/src/testutil/proxy.d.ts +8 -0
  49. package/dist/src/testutil/proxy.d.ts.map +1 -1
  50. package/dist/src/user/client.d.ts +1 -2
  51. package/dist/src/user/client.d.ts.map +1 -1
  52. package/dist/testutil.js +80 -60
  53. package/dist/testutil.js.map +1 -1
  54. package/package.json +12 -12
  55. package/src/access/policy/client.ts +0 -2
  56. package/src/arc/client.ts +2 -20
  57. package/src/channel/client.ts +12 -8
  58. package/src/device/client.ts +4 -7
  59. package/src/framer/cache/cache.ts +9 -1
  60. package/src/framer/cache/reader.ts +5 -3
  61. package/src/framer/cache/static.ts +86 -3
  62. package/src/framer/cache/unary.ts +7 -0
  63. package/src/framer/feed.ts +2 -0
  64. package/src/framer/streamer.ts +71 -6
  65. package/src/framer/writer.ts +4 -5
  66. package/src/index.ts +2 -0
  67. package/src/lineplot/actions.gen.ts +30 -0
  68. package/src/lineplot/actions.ts +14 -0
  69. package/src/lineplot/types.gen.ts +59 -21
  70. package/src/panel/client.ts +0 -2
  71. package/src/project/client.ts +0 -2
  72. package/src/query/indexes.ts +18 -7
  73. package/src/query/table.ts +52 -11
  74. package/src/rack/client.ts +5 -4
  75. package/src/ranger/client.ts +43 -12
  76. package/src/schematic/actions.ts +21 -10
  77. package/src/status/client.ts +1 -3
  78. package/src/status/status.ts +105 -1
  79. package/src/task/client.ts +57 -25
  80. package/src/testutil/client.ts +5 -1
  81. package/src/testutil/proxy.ts +59 -6
  82. package/src/user/client.ts +0 -2
  83. package/dist/client-BsVW3qAW.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,
@@ -386,7 +401,7 @@ export class Client extends query.Retriever<
386
401
  /** The range alias table; injected into sibling clients at wiring. */
387
402
  readonly aliases: query.Table<string, alias.Alias>;
388
403
  /** Cached queries for the children of a range, keyed by the parent's key. */
389
- readonly children: query.Retrieves<Key, Range[]>;
404
+ readonly children: query.Retrieves<ChildrenParams, Range[]>;
390
405
  /**
391
406
  * Cached queries for the closest range parent of a resource, keyed by the
392
407
  * child's ontology ID.
@@ -429,7 +444,7 @@ export class Client extends query.Retriever<
429
444
  table: composed,
430
445
  fetch: async (query) => [(await this.fetchSingle(query)).key],
431
446
  compose: ([record]) => record,
432
- keyOf: (query) => (keyZ.safeParse(query).success ? query : null),
447
+ keyOf: (query) => (z.validate(keyZ, query) ? query : null),
433
448
  matches: (r, query) => r.key === query || r.name === query,
434
449
  single: true,
435
450
  });
@@ -453,17 +468,27 @@ export class Client extends query.Retriever<
453
468
  this.store = ranges;
454
469
  this.kvPairs = kvPairs;
455
470
  this.aliases = aliases;
456
- this.children = cache.queries<Key, Range[], Key, Range>({
471
+ const children = cache.queries<ChildrenRequest, Range[], Key, Range>({
457
472
  name: "child ranges",
458
473
  table: composed,
459
474
  fetch: async (query) => (await this.fetchChildren(query)).map((r) => r.key),
460
475
  compose: (records) => records,
461
476
  matches: (r, query) => {
462
477
  const parent = this.cfg.ontology.cache.parentID(ontologyID(r.key));
463
- return parent != null && ontology.idsEqual(parent, ontologyID(query));
478
+ return parent != null && ontology.idsEqual(parent, ontologyID(query.key));
464
479
  },
465
- watch: [watchRelationships<Key>(this.cfg.ontology.cache.relationships)],
480
+ serverFields: CHILDREN_SERVER_FIELDS,
481
+ watch: [
482
+ watchRelationships<ChildrenRequest>(this.cfg.ontology.cache.relationships),
483
+ ],
466
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
+ };
467
492
  this.parent = cache.queries<ontology.ID, Range | null, Key, Range>({
468
493
  name: "parent range",
469
494
  table: composed,
@@ -551,9 +576,9 @@ export class Client extends query.Retriever<
551
576
  }
552
577
 
553
578
  async retrieve(params: Key | Name): Promise<Range>;
554
- async retrieve(params: Key[] | Name[]): Promise<Range[]>;
555
- async retrieve(params: CrudeTimeRange): Promise<Range[]>;
556
- async retrieve(params: RetrieveRequest): Promise<Range[]>;
579
+ async retrieve(
580
+ params: Key[] | Name[] | CrudeTimeRange | RetrieveRequest,
581
+ ): Promise<Range[]>;
557
582
  async retrieve(params: RetrieveParams): Promise<Range | Range[]> {
558
583
  // The branches narrow params onto different base overloads.
559
584
  if (typeof params === "string") return await super.retrieve(params);
@@ -669,8 +694,12 @@ export class Client extends query.Retriever<
669
694
  private async fetchSingle(query: Key | Name): Promise<Range> {
670
695
  const cached = this.store.get(query);
671
696
  if (cached != null) return this.composeOne(cached);
672
- const req = keyZ.safeParse(query).success ? { keys: [query] } : { names: [query] };
673
- const ranges = await this.execRetrieve({ ...BASE_REQUEST, ...req });
697
+ if (z.validate(keyZ, query)) {
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] });
674
703
  checkForMultipleOrNoResults("Range", query, ranges, true);
675
704
  this.writeThrough(ranges[0]);
676
705
  return ranges[0];
@@ -721,10 +750,12 @@ export class Client extends query.Retriever<
721
750
  return true;
722
751
  }
723
752
 
724
- private async fetchChildren(query: Key): Promise<Range[]> {
753
+ private async fetchChildren(query: ChildrenRequest): Promise<Range[]> {
725
754
  const resources = await this.cfg.ontology.children.retrieve({
726
- ids: ontologyID(query),
755
+ ids: ontologyID(query.key),
727
756
  types: ["range"],
757
+ limit: query.limit,
758
+ offset: query.offset,
728
759
  });
729
760
  if (resources.length === 0) return [];
730
761
  return await this.store.retrieve(resources.map(({ id: { key } }) => key));
@@ -23,6 +23,9 @@ import {
23
23
  setNodePosition,
24
24
  } from "@/schematic/actions.gen";
25
25
 
26
+ // Must match the pluto groupBox symbol's config variant.
27
+ const GROUP_BOX_VARIANT = "groupBox";
28
+
26
29
  const handlers: Handlers = {
27
30
  create: (state, payload) => {
28
31
  Object.assign(state, payload.schematic);
@@ -83,16 +86,24 @@ const handlers: Handlers = {
83
86
  oldConfigRaw != null ? actions.snapshotDraft(oldConfigRaw) : undefined;
84
87
  state.nodes.splice(idx, 1);
85
88
  delete state.configs[payload.key];
86
- return {
87
- inverse: [
88
- setNode(
89
- oldConfig != null
90
- ? { node: oldNode, config: oldConfig }
91
- : { node: oldNode, config: undefined },
92
- ),
93
- ],
94
- targets: [payload.key],
95
- };
89
+ const inverse: Action[] = [
90
+ setNode(
91
+ oldConfig != null
92
+ ? { node: oldNode, config: oldConfig }
93
+ : { node: oldNode, config: undefined },
94
+ ),
95
+ ];
96
+ const targets = [payload.key];
97
+ for (const [key, config] of Object.entries(state.configs)) {
98
+ if (config?.variant !== GROUP_BOX_VARIANT) continue;
99
+ const members = config.members;
100
+ if (!Array.isArray(members) || !members.includes(payload.key)) continue;
101
+ const oldMembers = actions.snapshotDraft(members);
102
+ config.members = members.filter((m) => m !== payload.key);
103
+ inverse.push(setConfig({ key, config: { members: oldMembers } }));
104
+ targets.push(key);
105
+ }
106
+ return { inverse, targets };
96
107
  },
97
108
  addEdge: (state, payload) => {
98
109
  if (state.edges.some((e) => e.key === payload.edge.key))
@@ -319,9 +319,7 @@ export class Client extends query.Retriever<
319
319
  }
320
320
 
321
321
  private async fetchSingle(key: Key): Promise<Status> {
322
- const cached = this.store.get(key);
323
- if (cached != null) return cached;
324
- const statuses = await this.fetchThrough({ keys: [key] });
322
+ const statuses = await this.store.retrieve([key]);
325
323
  checkForMultipleOrNoResults("Status", key, statuses, true);
326
324
  return statuses[0];
327
325
  }
@@ -80,6 +80,96 @@ export const exceptionDetailsSchema = z
80
80
  })
81
81
  .and(record.unknownZ());
82
82
 
83
+ // Bounds the clone check so it stays cheap even when errors are created in a tight
84
+ // loop. An exhausted budget reports un-cloneable, which safely over-rebuilds.
85
+ const CLONE_CHECK_BUDGET = 64;
86
+
87
+ const isCloneableValue = (
88
+ v: unknown,
89
+ seen: Set<object>,
90
+ budget: { left: number },
91
+ ): boolean => {
92
+ if (--budget.left < 0) return false;
93
+ if (v == null) return true;
94
+ const t = typeof v;
95
+ if (t === "function" || t === "symbol") return false;
96
+ if (t !== "object") return true;
97
+ const o = v;
98
+ if (seen.has(o)) return true;
99
+ seen.add(o);
100
+ const values = (vals: unknown[]): boolean =>
101
+ vals.every((e) => isCloneableValue(e, seen, budget));
102
+ if (o instanceof Error)
103
+ return isCloneableValue(o.cause, seen, budget) && values(Object.values(o));
104
+ if (Array.isArray(o)) return values(o);
105
+ const proto = Object.getPrototypeOf(o);
106
+ if (proto === Object.prototype || proto === null) return values(Object.values(o));
107
+ if (o instanceof Date || o instanceof ArrayBuffer || ArrayBuffer.isView(o))
108
+ return true;
109
+ // Host objects (an Event, a DOM node) and exotic containers land here.
110
+ return false;
111
+ };
112
+
113
+ // Object.values can trip an enumerable getter that throws. Treat a throwing value
114
+ // as not cloneable rather than throwing while already handling an error.
115
+ const isCloneable = (v: unknown): boolean => {
116
+ try {
117
+ return isCloneableValue(v, new Set(), { left: CLONE_CHECK_BUDGET });
118
+ } catch {
119
+ return false;
120
+ }
121
+ };
122
+
123
+ // Returns a cloneable stand-in for an un-cloneable value.
124
+ const cloneSafeValue = (v: unknown, depth: number): unknown => {
125
+ if (isCloneable(v)) return v;
126
+ if (v instanceof Error) return cloneSafeError(v, depth);
127
+ if (depth <= 0) return errors.fromUnknown(v).message;
128
+ try {
129
+ if (Array.isArray(v)) return v.map((e) => cloneSafeValue(e, depth - 1));
130
+ if (typeof v === "object" && v != null) {
131
+ const out: Record<string, unknown> = {};
132
+ for (const [key, value] of Object.entries(v))
133
+ if (typeof value !== "function" && typeof value !== "symbol")
134
+ out[key] = cloneSafeValue(value, depth - 1);
135
+ return out;
136
+ }
137
+ } catch {
138
+ // Tolerate throwing enumerable getters.
139
+ }
140
+ return errors.fromUnknown(v).message;
141
+ };
142
+
143
+ /**
144
+ * Returns err unchanged when it survives structured cloning. Otherwise rebuilds it
145
+ * with the same name, message, stack, and `type`, sanitizing un-cloneable fields and
146
+ * causes. Statuses cross the worker boundary, where an un-cloneable error kills the
147
+ * worker.
148
+ */
149
+ const cloneSafeError = (err: Error, depth: number = 8): Error => {
150
+ if (isCloneable(err)) return err;
151
+ const safe = new Error(err.message);
152
+ safe.name = err.name;
153
+ safe.stack = err.stack;
154
+ const { type } = err as { type?: unknown };
155
+ if (typeof type === "string") (safe as { type?: string }).type = type;
156
+ try {
157
+ for (const [key, value] of Object.entries(err))
158
+ if (key !== "cause" && typeof value !== "function" && typeof value !== "symbol")
159
+ (safe as unknown as Record<string, unknown>)[key] = cloneSafeValue(
160
+ value,
161
+ depth - 1,
162
+ );
163
+ } catch {
164
+ // Tolerate throwing enumerable getters.
165
+ }
166
+ const { cause } = err;
167
+ if (cause !== undefined && depth > 0)
168
+ if (cause instanceof Error) safe.cause = cloneSafeError(cause, depth - 1);
169
+ else safe.cause = cloneSafeValue(cause, depth - 1);
170
+ return safe;
171
+ };
172
+
83
173
  /** Flattens an error's cause chain into one readable line. */
84
174
  const causeChain = (err: Error): string | undefined => {
85
175
  const parts: string[] = [];
@@ -109,7 +199,7 @@ export const fromException = (
109
199
  message != null
110
200
  ? [err.message, detail].filter((part) => part != null).join(": ")
111
201
  : detail,
112
- details: { stack: err.stack ?? "", error: err },
202
+ details: { stack: err.stack ?? "", error: cloneSafeError(err) },
113
203
  };
114
204
  // Probe the original (pre-coercion) value so a non-Error throwable with a custom
115
205
  // `toStatus()` method still contributes its status fields.
@@ -192,6 +282,20 @@ export const removeVariants = (
192
282
  return remove === variant ? undefined : variant;
193
283
  };
194
284
 
285
+ /** Rank of each variant by severity, most severe lowest. */
286
+ const SEVERITY: Record<Variant, number> = {
287
+ error: 0,
288
+ warning: 1,
289
+ loading: 2,
290
+ success: 3,
291
+ info: 4,
292
+ disabled: 5,
293
+ };
294
+
295
+ /** @returns true when a is more severe than b. */
296
+ export const moreSevere = (a: Variant, b: Variant): boolean =>
297
+ SEVERITY[a] < SEVERITY[b];
298
+
195
299
  /** Options for {@link toString}. */
196
300
  export interface ToStringOptions {
197
301
  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>;
@@ -587,7 +600,7 @@ export class Client extends query.Retriever<
587
600
  ): Promise<Task<S> | Task<S>[]> {
588
601
  const { schemas, ...params } =
589
602
  typeof rawParams === "string" ? { key: rawParams } : rawParams;
590
- const isSingle = singleRetrieveParamsZ.safeParse(params).success;
603
+ const isSingle = z.validate(singleRetrieveParamsZ, params);
591
604
  // Schema-parametrized retrieves validate config/status for one caller;
592
605
  // their results are not shared through the cache.
593
606
  if (schemas != null) {
@@ -595,11 +608,8 @@ export class Client extends query.Retriever<
595
608
  checkForMultipleOrNoResults("Task", params, sugared, isSingle);
596
609
  return isSingle ? sugared[0] : sugared;
597
610
  }
598
- if (isSingle)
599
- return (await super.retrieve(params as RetrieveSingleParams)) as Task<S>;
600
- return (await super.retrieve(
601
- params as RetrieveMultipleParams,
602
- )) as unknown as Task<S>[];
611
+ if (isSingle) return (await super.retrieve(params)) as Task<S>;
612
+ return (await super.retrieve(params)) as unknown as Task<S>[];
603
613
  }
604
614
 
605
615
  async copy(key: Key, name: string, snapshot: boolean): Promise<Task> {
@@ -719,20 +729,25 @@ export class Client extends query.Retriever<
719
729
  );
720
730
  }
721
731
 
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.
732
+ // A task's status may live under the "task:<key>" row or under any status whose
733
+ // details reference the task; the freshest wins, ties going to the more severe. Rows
734
+ // are parsed because the status table holds every domain's statuses generically.
725
735
  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))
736
+ const direct = this.cfg.statusStore.get(statusKey(key));
737
+ const rows = this.statusesByTask.get(key);
738
+ // A task status names its task in details too, so the index usually holds it.
739
+ if (direct != null && !rows.includes(direct)) rows.push(direct);
740
+ const candidates = rows
741
+ .map((s) => defaultStatusZ.safeParse(s))
730
742
  .filter((p) => p.success)
731
743
  .map((p) => p.data);
732
744
  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
- );
745
+ return candidates.reduce((latest, s) => {
746
+ const time = new TimeStamp(s.time);
747
+ const latestTime = new TimeStamp(latest.time);
748
+ if (!time.equals(latestTime)) return time.after(latestTime) ? s : latest;
749
+ return status.moreSevere(s.variant, latest.variant) ? s : latest;
750
+ });
736
751
  }
737
752
 
738
753
  /** Writes a fetched task and its included status. */
@@ -759,6 +774,12 @@ export class Client extends query.Retriever<
759
774
  // no status or the status may not have synced. Only both count as a hit.
760
775
  if (cached != null && this.cfg.statusStore.has(statusKey(cached.key)))
761
776
  return this.compose(cached);
777
+ if (primitive.isNonZero(q.keys)) {
778
+ // The refresh covers a cached task whose status has not synced.
779
+ const tasks = await this.store.retrieve(q.keys, { refresh: true });
780
+ checkForMultipleOrNoResults("Task", q, tasks, true);
781
+ return this.compose(tasks[0]);
782
+ }
762
783
  const tasks = await this.execRetrieve({ ...q, includeStatus: true });
763
784
  checkForMultipleOrNoResults("Task", q, tasks, true);
764
785
  this.writeThrough(tasks[0]);
@@ -866,17 +887,28 @@ export class Client extends query.Retriever<
866
887
  /** @returns the key of the status that reports on the given task. */
867
888
  export const statusKey = (key: Key): string => ontology.idToString(ontologyID(key));
868
889
 
869
- const taskStatusZ = z.object({ details: z.object({ task: keyZ }) });
890
+ /**
891
+ * @returns the key of the task the status reports on, or null when it names none.
892
+ * Guarded before the key parse: most statuses in the table belong to other domains,
893
+ * and a failed zod parse costs orders of magnitude more than the miss is worth.
894
+ */
895
+ const statusTaskKey = (s: status.Status): Key | null => {
896
+ const task = status.detailsOf(s)?.task;
897
+ if (typeof task !== "string") return null;
898
+ return z.validate(keyZ, task) ? task : null;
899
+ };
870
900
 
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 = (
901
+ /**
902
+ * @returns the keys of the tasks a status change affects, or null when none. A status
903
+ * names its task in `details.task`, with the "task:<key>" status key as a fallback.
904
+ */
905
+ export const affectedKeys = (
874
906
  event: query.TableEvent<status.Key, status.Status>,
875
907
  ): Key[] | null => {
876
908
  const keys: Key[] = [];
877
909
  if (event.variant === "set") {
878
- const parsed = taskStatusZ.safeParse(event.value);
879
- if (parsed.success) keys.push(parsed.data.details.task);
910
+ const task = statusTaskKey(event.value);
911
+ if (task != null) keys.push(task);
880
912
  }
881
913
  const [type, key] = event.key.split(":");
882
914
  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);
@@ -207,8 +207,6 @@ export class Client extends query.Retriever<
207
207
  });
208
208
  }
209
209
 
210
- async delete(key: Key, opts?: query.WriteOptions): Promise<void>;
211
- async delete(keys: Key[], opts?: query.WriteOptions): Promise<void>;
212
210
  async delete(keys: Key | Key[], opts: query.WriteOptions = {}): Promise<void> {
213
211
  const keysArr = array.toArray(keys);
214
212
  const drop = () => [