@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.
- package/dist/{client-DDACDVZ-.js → client-CX6CMvCc.js} +3686 -3548
- package/dist/client-CX6CMvCc.js.map +1 -0
- package/dist/client.js +1 -1
- package/dist/src/arc/client.d.ts.map +1 -1
- package/dist/src/channel/client.d.ts +1 -0
- package/dist/src/channel/client.d.ts.map +1 -1
- package/dist/src/device/client.d.ts.map +1 -1
- package/dist/src/framer/streamer.d.ts +34 -18
- package/dist/src/framer/streamer.d.ts.map +1 -1
- package/dist/src/label/store.d.ts +1 -1
- package/dist/src/label/store.d.ts.map +1 -1
- package/dist/src/ontology/client.d.ts.map +1 -1
- package/dist/src/ontology/store.d.ts +16 -1
- package/dist/src/ontology/store.d.ts.map +1 -1
- package/dist/src/query/external.d.ts +1 -0
- package/dist/src/query/external.d.ts.map +1 -1
- package/dist/src/query/indexes.d.ts +38 -0
- package/dist/src/query/indexes.d.ts.map +1 -0
- package/dist/src/query/table.d.ts +20 -4
- package/dist/src/query/table.d.ts.map +1 -1
- package/dist/src/rack/client.d.ts.map +1 -1
- package/dist/src/ranger/client.d.ts +11 -1
- package/dist/src/ranger/client.d.ts.map +1 -1
- package/dist/src/status/client.d.ts.map +1 -1
- package/dist/src/status/status.d.ts +2 -0
- package/dist/src/status/status.d.ts.map +1 -1
- package/dist/src/task/client.d.ts +19 -1
- package/dist/src/task/client.d.ts.map +1 -1
- package/dist/src/testutil/client.d.ts +2 -0
- package/dist/src/testutil/client.d.ts.map +1 -1
- package/dist/src/testutil/proxy.d.ts +8 -0
- package/dist/src/testutil/proxy.d.ts.map +1 -1
- package/dist/testutil.js +80 -60
- package/dist/testutil.js.map +1 -1
- package/package.json +5 -5
- package/src/arc/client.ts +2 -20
- package/src/channel/client.ts +11 -6
- package/src/device/client.ts +4 -3
- package/src/framer/streamer.ts +71 -6
- package/src/label/store.ts +7 -2
- package/src/ontology/client.ts +15 -8
- package/src/ontology/store.ts +27 -5
- package/src/query/external.ts +1 -0
- package/src/query/indexes.ts +94 -0
- package/src/query/table.ts +74 -12
- package/src/rack/client.ts +5 -4
- package/src/ranger/client.ts +65 -24
- package/src/status/client.ts +15 -11
- package/src/status/status.ts +14 -0
- package/src/task/client.ts +54 -19
- package/src/testutil/client.ts +5 -1
- package/src/testutil/proxy.ts +59 -6
- package/dist/client-DDACDVZ-.js.map +0 -1
package/src/ranger/client.ts
CHANGED
|
@@ -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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
373
|
+
cache: ontology.Cache,
|
|
362
374
|
): query.Watch<Q, Key> =>
|
|
363
375
|
query.watch<Q, Key, label.Key, label.Label>(labels, (event) =>
|
|
364
|
-
rangesWithLabel(
|
|
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<
|
|
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) =>
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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));
|
package/src/status/client.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
366
|
-
.
|
|
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
|
}
|
package/src/status/status.ts
CHANGED
|
@@ -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;
|
package/src/task/client.ts
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
//
|
|
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
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
879
|
-
if (
|
|
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))
|
package/src/testutil/client.ts
CHANGED
|
@@ -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");
|
package/src/testutil/proxy.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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);
|