@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.
- package/dist/{client-BsVW3qAW.js → client-CX6CMvCc.js} +3617 -3528
- 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/query/indexes.d.ts +10 -4
- package/dist/src/query/indexes.d.ts.map +1 -1
- package/dist/src/query/table.d.ts +10 -3
- 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 +9 -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 +3 -3
- 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/query/indexes.ts +19 -6
- package/src/query/table.ts +50 -8
- package/src/rack/client.ts +5 -4
- package/src/ranger/client.ts +39 -8
- package/src/status/client.ts +1 -3
- 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-BsVW3qAW.js.map +0 -1
package/src/framer/streamer.ts
CHANGED
|
@@ -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 {
|
|
11
|
-
|
|
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({
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|
package/src/query/indexes.ts
CHANGED
|
@@ -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
|
|
15
|
-
* param; the table keeps it current
|
|
16
|
-
* included. Tombstones are never
|
|
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();
|
package/src/query/table.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
331
|
-
*
|
|
332
|
-
* omitted. Tables without a fetch
|
|
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
|
|
342
|
-
if (opts.refresh === true)
|
|
343
|
-
|
|
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>();
|
package/src/rack/client.ts
CHANGED
|
@@ -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
|
|
311
|
-
//
|
|
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
|
|
314
|
-
|
|
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);
|
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,
|
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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:
|
|
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));
|
package/src/status/client.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
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");
|