@synnaxlabs/client 0.57.2 → 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 (42) hide show
  1. package/dist/{client-BsVW3qAW.js → client-CX6CMvCc.js} +3617 -3528
  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/query/indexes.d.ts +10 -4
  11. package/dist/src/query/indexes.d.ts.map +1 -1
  12. package/dist/src/query/table.d.ts +10 -3
  13. package/dist/src/query/table.d.ts.map +1 -1
  14. package/dist/src/rack/client.d.ts.map +1 -1
  15. package/dist/src/ranger/client.d.ts +9 -1
  16. package/dist/src/ranger/client.d.ts.map +1 -1
  17. package/dist/src/status/client.d.ts.map +1 -1
  18. package/dist/src/status/status.d.ts +2 -0
  19. package/dist/src/status/status.d.ts.map +1 -1
  20. package/dist/src/task/client.d.ts +19 -1
  21. package/dist/src/task/client.d.ts.map +1 -1
  22. package/dist/src/testutil/client.d.ts +2 -0
  23. package/dist/src/testutil/client.d.ts.map +1 -1
  24. package/dist/src/testutil/proxy.d.ts +8 -0
  25. package/dist/src/testutil/proxy.d.ts.map +1 -1
  26. package/dist/testutil.js +80 -60
  27. package/dist/testutil.js.map +1 -1
  28. package/package.json +3 -3
  29. package/src/arc/client.ts +2 -20
  30. package/src/channel/client.ts +11 -6
  31. package/src/device/client.ts +4 -3
  32. package/src/framer/streamer.ts +71 -6
  33. package/src/query/indexes.ts +19 -6
  34. package/src/query/table.ts +50 -8
  35. package/src/rack/client.ts +5 -4
  36. package/src/ranger/client.ts +39 -8
  37. package/src/status/client.ts +1 -3
  38. package/src/status/status.ts +14 -0
  39. package/src/task/client.ts +54 -19
  40. package/src/testutil/client.ts +5 -1
  41. package/src/testutil/proxy.ts +59 -6
  42. package/dist/client-BsVW3qAW.js.map +0 -1
@@ -7,8 +7,13 @@
7
7
  // License, use of this software will be governed by the Apache License, Version 2.0,
8
8
  // included in the file licenses/APL.txt.
9
9
 
10
- import { EOF, type Stream, type WebSocketClient } from "@synnaxlabs/freighter";
11
- import { errors, Rate, zod } from "@synnaxlabs/x";
10
+ import {
11
+ EOF,
12
+ type Stream,
13
+ Unreachable,
14
+ type WebSocketClient,
15
+ } from "@synnaxlabs/freighter";
16
+ import { errors, Rate, TimeSpan, zod } from "@synnaxlabs/x";
12
17
  import { z } from "zod";
13
18
 
14
19
  import { type channel } from "@/channel";
@@ -23,6 +28,7 @@ const reqZ = z.object({
23
28
  downsampleFactor: z.int(),
24
29
  throttleRate: Rate.z.optional(),
25
30
  excludeGroups: z.uint32().array().optional(),
31
+ keepAlive: TimeSpan.z.optional(),
26
32
  });
27
33
 
28
34
  /**
@@ -31,7 +37,11 @@ const reqZ = z.object({
31
37
  */
32
38
  export interface StreamerRequest extends z.infer<typeof reqZ> {}
33
39
 
34
- const resZ = z.object({ frame: frameZ });
40
+ const resZ = z.object({
41
+ frame: frameZ,
42
+ /** Marks an empty response the Core emits so a dead connection is detectable. */
43
+ keepAlive: z.boolean().optional(),
44
+ });
35
45
 
36
46
  /**
37
47
  * Response interface for streaming frames from a Synnax cluster.
@@ -49,6 +59,10 @@ const intermediateStreamerConfigZ = z.object({
49
59
  /** excludeGroups sets writer group IDs whose frames should be filtered out by the
50
60
  Core. Used for telemetry bypass deduplication. */
51
61
  excludeGroups: z.uint32().array().default([]),
62
+ /** Interval at which the Core emits keep-alive responses so a silently dead
63
+ connection fails reads instead of hanging forever. TimeSpan.ZERO disables
64
+ detection. Defaults to 5 seconds. */
65
+ keepAlive: TimeSpan.z.default(TimeSpan.seconds(5)),
52
66
  });
53
67
 
54
68
  /** Zod schema for {@link StreamerConfig}. A bare channel list parses as a config. */
@@ -73,7 +87,11 @@ export interface Streamer extends AsyncIterator<Frame>, AsyncIterable<Frame> {
73
87
  update: (channels: channel.Params) => Promise<void>;
74
88
  /** Close the streamer and free all associated resources. */
75
89
  close: () => void;
76
- /** Read the next frame of telemetry. */
90
+ /**
91
+ * Read the next frame of telemetry.
92
+ * @throws {Unreachable} if keep-alives were flowing and the stream then stays silent
93
+ * past the keep-alive deadline: the connection is presumed dead.
94
+ */
77
95
  read: () => Promise<Frame>;
78
96
  }
79
97
 
@@ -101,14 +119,19 @@ export const createStreamOpener =
101
119
  cfg.downsampleFactor,
102
120
  cfg.throttleRate,
103
121
  cfg.excludeGroups,
122
+ cfg.keepAlive,
104
123
  );
105
124
  stream.send({
106
125
  keys: Array.from(adapter.keys),
107
126
  downsampleFactor: cfg.downsampleFactor,
108
127
  throttleRate: cfg.throttleRate,
109
128
  excludeGroups: cfg.excludeGroups,
129
+ keepAlive: cfg.keepAlive,
110
130
  });
111
- await stream.receive();
131
+ // A keep-alive can beat the open ack onto the wire, so the ack is the first
132
+ // non-keep-alive response.
133
+ let res = await stream.receive();
134
+ while (res.keepAlive === true) res = await stream.receive();
112
135
  return streamer;
113
136
  };
114
137
 
@@ -124,12 +147,20 @@ export const openStreamer = async (
124
147
  config: StreamerConfig,
125
148
  ): Promise<Streamer> => await createStreamOpener(retrieveChannels, client)(config);
126
149
 
150
+ // Missing this many keep-alive intervals in a row fails the pending read: one is normal
151
+ // jitter, three is a dead connection.
152
+ const KEEP_ALIVE_DEADLINE_FACTOR = 3;
153
+
127
154
  class BaseStreamer implements Streamer {
128
155
  private readonly stream: StreamProxy<typeof reqZ, typeof resZ>;
129
156
  private readonly adapter: ReadAdapter;
130
157
  private readonly downsampleFactor: number;
131
158
  private readonly throttleRate: Rate;
132
159
  private readonly excludeGroups: number[];
160
+ private readonly deadline: TimeSpan;
161
+ // Set once the Core proves keep-alive support by sending one, so the deadline never
162
+ // arms against a Core that will not send them.
163
+ private armed = false;
133
164
 
134
165
  constructor(
135
166
  stream: Stream<typeof reqZ, typeof resZ>,
@@ -137,12 +168,16 @@ class BaseStreamer implements Streamer {
137
168
  downsampleFactor: number = 1,
138
169
  throttleRate: Rate = new Rate(0),
139
170
  excludeGroups: number[] = [],
171
+ keepAlive: TimeSpan = TimeSpan.ZERO,
140
172
  ) {
141
173
  this.stream = new StreamProxy("Streamer", stream);
142
174
  this.adapter = adapter;
143
175
  this.downsampleFactor = downsampleFactor;
144
176
  this.throttleRate = throttleRate;
145
177
  this.excludeGroups = excludeGroups;
178
+ this.deadline = TimeSpan.milliseconds(
179
+ keepAlive.milliseconds * KEEP_ALIVE_DEADLINE_FACTOR,
180
+ );
146
181
  }
147
182
 
148
183
  get keys(): channel.Key[] {
@@ -160,7 +195,37 @@ class BaseStreamer implements Streamer {
160
195
  }
161
196
 
162
197
  async read(): Promise<Frame> {
163
- return this.adapter.adapt(new Frame((await this.stream.receive()).frame));
198
+ while (true) {
199
+ const res = await this.receiveWithDeadline();
200
+ if (res.keepAlive === true) {
201
+ if (!this.deadline.isZero) this.armed = true;
202
+ continue;
203
+ }
204
+ return this.adapter.adapt(new Frame(res.frame));
205
+ }
206
+ }
207
+
208
+ private async receiveWithDeadline(): Promise<z.infer<typeof resZ>> {
209
+ const received = this.stream.receive();
210
+ if (!this.armed) return await received;
211
+ let timer: ReturnType<typeof setTimeout> | undefined;
212
+ const deadline = new Promise<never>((_, reject) => {
213
+ timer = setTimeout(() => {
214
+ const silence = this.deadline.toString();
215
+ const message = `streamer received no response for ${silence}`;
216
+ reject(new Unreachable({ message }));
217
+ }, this.deadline.milliseconds);
218
+ });
219
+ try {
220
+ return await Promise.race([received, deadline]);
221
+ } catch (err) {
222
+ // The read already failed for its caller; a late settle of the losing receive
223
+ // must not surface as an unhandled rejection.
224
+ received.catch(() => {});
225
+ throw errors.fromUnknown(err);
226
+ } finally {
227
+ clearTimeout(timer);
228
+ }
164
229
  }
165
230
 
166
231
  async update(channels: channel.Params): Promise<void> {
@@ -11,25 +11,34 @@ import { type record, type state } from "@synnaxlabs/x";
11
11
 
12
12
  /**
13
13
  * An exact-match secondary index over a table's live entries. `extract` reads one
14
- * index value from each entry. Register the index on its owning table's `indexes`
15
- * param; the table keeps it current across every mutation, rollbacks and resets
16
- * included. Tombstones are never indexed.
14
+ * index value from each entry; a null leaves the entry out of the index. Register
15
+ * the index on its owning table's `indexes` param; the table keeps it current
16
+ * across every mutation, rollbacks and resets included. Tombstones are never
17
+ * indexed.
17
18
  */
18
19
  export class LookupIndex<
19
20
  Key extends record.Key = record.Key,
20
21
  Value extends state.State = state.State,
21
22
  IndexValue extends record.Key = record.Key,
22
23
  > {
23
- private readonly extract: (value: Value) => IndexValue;
24
+ private readonly extract: (value: Value) => IndexValue | null;
24
25
  private readonly buckets = new Map<IndexValue, Map<Key, Value>>();
25
26
  private readonly indexed = new Map<Key, IndexValue>();
26
27
 
27
- constructor(extract: (value: Value) => IndexValue) {
28
+ constructor(extract: (value: Value) => IndexValue | null) {
28
29
  this.extract = extract;
29
30
  }
30
31
 
31
32
  /** Returns the entries whose extracted index value equals the given value. */
32
- get(value: IndexValue): Value[] {
33
+ get(value: IndexValue): Value[];
34
+ /**
35
+ * Returns the entries matching any of the given values, in the order of the
36
+ * values and each entry once.
37
+ */
38
+ get(values: IndexValue[]): Value[];
39
+ get(value: IndexValue | IndexValue[]): Value[] {
40
+ if (Array.isArray(value))
41
+ return Array.from(new Set(value)).flatMap((v) => this.get(v));
33
42
  const bucket = this.buckets.get(value);
34
43
  if (bucket == null) return [];
35
44
  return Array.from(bucket.values());
@@ -43,6 +52,10 @@ export class LookupIndex<
43
52
  const next = this.extract(value);
44
53
  const prev = this.indexed.get(key);
45
54
  if (prev !== undefined && prev !== next) this.unbucket(prev, key);
55
+ if (next == null) {
56
+ this.indexed.delete(key);
57
+ return;
58
+ }
46
59
  let bucket = this.buckets.get(next);
47
60
  if (bucket == null) {
48
61
  bucket = new Map();
@@ -177,7 +177,7 @@ export class Table<
177
177
  this.equal = equal;
178
178
  this.fetchEntries = fetch;
179
179
  this.hydrateMode = hydrate;
180
- this.indexes = indexes;
180
+ this.indexes = [...indexes];
181
181
  this.fetchBatcher =
182
182
  fetch == null
183
183
  ? null
@@ -186,7 +186,27 @@ export class Table<
186
186
  exec: async (requests) => {
187
187
  const keys = new Set<Key>();
188
188
  requests.forEach(({ req }) => req.forEach((key) => keys.add(key)));
189
- const fetched = await fetch(Array.from(keys));
189
+ let fetched: Array<Keyed<Key, Value>>;
190
+ try {
191
+ fetched = await fetch(Array.from(keys));
192
+ } catch (exc) {
193
+ if (!NotFoundError.matches(exc) || requests.length === 1)
194
+ throw errors.fromUnknown(exc);
195
+ // A strict fetch rejects the whole batch when any caller's key
196
+ // has vanished. Refetch per caller so each settles exactly as
197
+ // its own request would have, keeping the batch transparent.
198
+ await Promise.all(
199
+ requests.map(async ({ req, resolve, reject }) => {
200
+ try {
201
+ const mine = new Set(req);
202
+ resolve((await fetch(req)).filter(({ key }) => mine.has(key)));
203
+ } catch (exc) {
204
+ reject(exc);
205
+ }
206
+ }),
207
+ );
208
+ return;
209
+ }
190
210
  // The window's fetch carries other callers' keys too; each caller
191
211
  // hydrates only the entries it asked for.
192
212
  requests.forEach(({ req, resolve }) => {
@@ -207,6 +227,17 @@ export class Table<
207
227
  for (const index of this.indexes) index.delete(key);
208
228
  }
209
229
 
230
+ /**
231
+ * Registers a secondary index and backfills it from the live entries, for a
232
+ * domain that owns an index's meaning but not the table it reads.
233
+ * @returns the index.
234
+ */
235
+ index<I extends LookupIndex<Key, Value>>(index: I): I {
236
+ this.entries.forEach((value, key) => index.set(key, value));
237
+ this.indexes.push(index);
238
+ return index;
239
+ }
240
+
210
241
  private setOne(
211
242
  key: Key,
212
243
  value: state.SetArg<Value | undefined>,
@@ -327,9 +358,10 @@ export class Table<
327
358
  /**
328
359
  * Resolves the given keys to records: serves cached entries and fetches the misses
329
360
  * through the table's fetch, hydrating results under the declared mode. With refresh,
330
- * every key is fetched regardless of presence. Returns the table's entries for the
331
- * found keys in input order, deduplicated; keys the cluster no longer has are
332
- * omitted. Tables without a fetch serve cached entries only.
361
+ * every key is fetched regardless of presence and cached entries the fetch omits are
362
+ * tombstoned. Returns the table's entries for the found keys in input order,
363
+ * deduplicated; keys the cluster no longer has are omitted. Tables without a fetch
364
+ * serve cached entries only.
333
365
  */
334
366
  async retrieve(keys: Key[], opts: { refresh?: boolean } = {}): Promise<Value[]> {
335
367
  if (this.fetchBatcher != null) {
@@ -338,9 +370,19 @@ export class Table<
338
370
  if (misses.length > 0) {
339
371
  const gen = this.gen;
340
372
  const fetched = await this.fetchBatcher.enqueue(misses);
341
- if (gen === this.gen && fetched.length > 0)
342
- if (opts.refresh === true) this.set(fetched);
343
- else this.ingest(fetched);
373
+ if (gen === this.gen)
374
+ if (opts.refresh === true) {
375
+ // A refresh is authoritative for its keys: cached entries the
376
+ // fetch omitted vanished from the cluster and are tombstoned.
377
+ const present = new Set<Key>(fetched.map(({ key }) => key));
378
+ const vanished = misses.filter(
379
+ (key) => !present.has(key) && this.entries.has(key),
380
+ );
381
+ this.batch(() => {
382
+ if (vanished.length > 0) this.delete(vanished);
383
+ if (fetched.length > 0) this.set(fetched);
384
+ });
385
+ } else if (fetched.length > 0) this.ingest(fetched);
344
386
  }
345
387
  }
346
388
  const seen = new Set<Key>();
@@ -307,11 +307,12 @@ export class Client extends query.Retriever<
307
307
  }
308
308
 
309
309
  private async fetchSingle(q: SingleQuery): Promise<Rack> {
310
- // Names are not unique, so only key queries can be served from the table.
311
- // A status-bearing hit needs both the record and its status cached.
310
+ // Names are not unique and the table never holds status, so only keyed status-free
311
+ // queries can be served from it.
312
312
  if ("key" in q && q.includeStatus !== true) {
313
- const cached = this.store.get(q.key);
314
- if (cached != null) return this.sugar(cached);
313
+ const racks = await this.store.retrieve([q.key]);
314
+ checkForMultipleOrNoResults("Rack", q, racks, true);
315
+ return this.sugar(racks[0]);
315
316
  }
316
317
  const racks = await this.execRetrieve(q);
317
318
  checkForMultipleOrNoResults("Rack", q, racks, true);
@@ -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.
@@ -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,
@@ -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 (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] });
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));
@@ -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
  }
@@ -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");