@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.
- package/dist/{client-BsVW3qAW.js → client-0I0a7Hkw.js} +10121 -9064
- package/dist/client-0I0a7Hkw.js.map +1 -0
- package/dist/client.js +2061 -978
- package/dist/client.js.map +1 -1
- package/dist/src/access/policy/client.d.ts +1 -2
- package/dist/src/access/policy/client.d.ts.map +1 -1
- package/dist/src/arc/client.d.ts.map +1 -1
- package/dist/src/channel/client.d.ts +2 -1
- package/dist/src/channel/client.d.ts.map +1 -1
- package/dist/src/device/client.d.ts +0 -2
- package/dist/src/device/client.d.ts.map +1 -1
- package/dist/src/framer/cache/cache.d.ts.map +1 -1
- package/dist/src/framer/cache/reader.d.ts.map +1 -1
- package/dist/src/framer/cache/static.d.ts +14 -0
- package/dist/src/framer/cache/static.d.ts.map +1 -1
- package/dist/src/framer/cache/unary.d.ts +3 -0
- package/dist/src/framer/cache/unary.d.ts.map +1 -1
- package/dist/src/framer/feed.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/framer/writer.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/lineplot/actions.d.ts +48 -0
- package/dist/src/lineplot/actions.d.ts.map +1 -1
- package/dist/src/lineplot/actions.gen.d.ts +237 -0
- package/dist/src/lineplot/actions.gen.d.ts.map +1 -1
- package/dist/src/lineplot/types.gen.d.ts +87 -7
- package/dist/src/lineplot/types.gen.d.ts.map +1 -1
- package/dist/src/panel/client.d.ts +1 -2
- package/dist/src/panel/client.d.ts.map +1 -1
- package/dist/src/project/client.d.ts +1 -2
- package/dist/src/project/client.d.ts.map +1 -1
- package/dist/src/query/indexes.d.ts +11 -6
- package/dist/src/query/indexes.d.ts.map +1 -1
- package/dist/src/query/table.d.ts +12 -6
- 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 +10 -4
- package/dist/src/ranger/client.d.ts.map +1 -1
- package/dist/src/schematic/actions.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/src/user/client.d.ts +1 -2
- package/dist/src/user/client.d.ts.map +1 -1
- package/dist/testutil.js +80 -60
- package/dist/testutil.js.map +1 -1
- package/package.json +12 -12
- package/src/access/policy/client.ts +0 -2
- package/src/arc/client.ts +2 -20
- package/src/channel/client.ts +12 -8
- package/src/device/client.ts +4 -7
- package/src/framer/cache/cache.ts +9 -1
- package/src/framer/cache/reader.ts +5 -3
- package/src/framer/cache/static.ts +86 -3
- package/src/framer/cache/unary.ts +7 -0
- package/src/framer/feed.ts +2 -0
- package/src/framer/streamer.ts +71 -6
- package/src/framer/writer.ts +4 -5
- package/src/index.ts +2 -0
- package/src/lineplot/actions.gen.ts +30 -0
- package/src/lineplot/actions.ts +14 -0
- package/src/lineplot/types.gen.ts +59 -21
- package/src/panel/client.ts +0 -2
- package/src/project/client.ts +0 -2
- package/src/query/indexes.ts +18 -7
- package/src/query/table.ts +52 -11
- package/src/rack/client.ts +5 -4
- package/src/ranger/client.ts +43 -12
- package/src/schematic/actions.ts +21 -10
- package/src/status/client.ts +1 -3
- package/src/status/status.ts +105 -1
- package/src/task/client.ts +57 -25
- package/src/testutil/client.ts +5 -1
- package/src/testutil/proxy.ts +59 -6
- package/src/user/client.ts +0 -2
- package/dist/client-BsVW3qAW.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,
|
|
@@ -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.
|
|
@@ -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) => (
|
|
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
|
-
|
|
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,
|
|
@@ -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(
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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:
|
|
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/schematic/actions.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
oldConfig
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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))
|
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
|
@@ -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;
|
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>;
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
879
|
-
if (
|
|
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))
|
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);
|
package/src/user/client.ts
CHANGED
|
@@ -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 = () => [
|