@synnaxlabs/client 0.57.3 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/{client-CX6CMvCc.js → client-0I0a7Hkw.js} +10152 -9184
  2. package/dist/client-0I0a7Hkw.js.map +1 -0
  3. package/dist/client.js +2061 -978
  4. package/dist/client.js.map +1 -1
  5. package/dist/src/access/policy/client.d.ts +1 -2
  6. package/dist/src/access/policy/client.d.ts.map +1 -1
  7. package/dist/src/channel/client.d.ts +1 -1
  8. package/dist/src/channel/client.d.ts.map +1 -1
  9. package/dist/src/device/client.d.ts +0 -2
  10. package/dist/src/device/client.d.ts.map +1 -1
  11. package/dist/src/framer/cache/cache.d.ts.map +1 -1
  12. package/dist/src/framer/cache/reader.d.ts.map +1 -1
  13. package/dist/src/framer/cache/static.d.ts +14 -0
  14. package/dist/src/framer/cache/static.d.ts.map +1 -1
  15. package/dist/src/framer/cache/unary.d.ts +3 -0
  16. package/dist/src/framer/cache/unary.d.ts.map +1 -1
  17. package/dist/src/framer/feed.d.ts.map +1 -1
  18. package/dist/src/framer/writer.d.ts.map +1 -1
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/lineplot/actions.d.ts +48 -0
  21. package/dist/src/lineplot/actions.d.ts.map +1 -1
  22. package/dist/src/lineplot/actions.gen.d.ts +237 -0
  23. package/dist/src/lineplot/actions.gen.d.ts.map +1 -1
  24. package/dist/src/lineplot/types.gen.d.ts +87 -7
  25. package/dist/src/lineplot/types.gen.d.ts.map +1 -1
  26. package/dist/src/panel/client.d.ts +1 -2
  27. package/dist/src/panel/client.d.ts.map +1 -1
  28. package/dist/src/project/client.d.ts +1 -2
  29. package/dist/src/project/client.d.ts.map +1 -1
  30. package/dist/src/query/indexes.d.ts +4 -5
  31. package/dist/src/query/indexes.d.ts.map +1 -1
  32. package/dist/src/query/table.d.ts +2 -3
  33. package/dist/src/query/table.d.ts.map +1 -1
  34. package/dist/src/ranger/client.d.ts +1 -3
  35. package/dist/src/ranger/client.d.ts.map +1 -1
  36. package/dist/src/schematic/actions.d.ts.map +1 -1
  37. package/dist/src/status/status.d.ts.map +1 -1
  38. package/dist/src/task/client.d.ts.map +1 -1
  39. package/dist/src/user/client.d.ts +1 -2
  40. package/dist/src/user/client.d.ts.map +1 -1
  41. package/dist/testutil.js +1 -1
  42. package/package.json +12 -12
  43. package/src/access/policy/client.ts +0 -2
  44. package/src/channel/client.ts +1 -2
  45. package/src/device/client.ts +0 -4
  46. package/src/framer/cache/cache.ts +9 -1
  47. package/src/framer/cache/reader.ts +5 -3
  48. package/src/framer/cache/static.ts +86 -3
  49. package/src/framer/cache/unary.ts +7 -0
  50. package/src/framer/feed.ts +2 -0
  51. package/src/framer/writer.ts +4 -5
  52. package/src/index.ts +2 -0
  53. package/src/lineplot/actions.gen.ts +30 -0
  54. package/src/lineplot/actions.ts +14 -0
  55. package/src/lineplot/types.gen.ts +59 -21
  56. package/src/panel/client.ts +0 -2
  57. package/src/project/client.ts +0 -2
  58. package/src/query/indexes.ts +3 -5
  59. package/src/query/table.ts +2 -3
  60. package/src/ranger/client.ts +5 -5
  61. package/src/schematic/actions.ts +21 -10
  62. package/src/status/status.ts +91 -1
  63. package/src/task/client.ts +4 -7
  64. package/src/user/client.ts +0 -2
  65. package/dist/client-CX6CMvCc.js.map +0 -1
@@ -46,12 +46,19 @@ export interface StaticProps {
46
46
  * be marked as stale and subject to garbage collection.
47
47
  * @default TimeSpan.seconds(20) */
48
48
  staleEntryThreshold?: TimeSpan;
49
+ /**
50
+ * Sets how long a fetched span counts as answered before it is refetched. Bounds
51
+ * the refetch rate of spans that stay empty, as every refetch opens a short-lived
52
+ * iterator connection.
53
+ * @default TimeSpan.minutes(10) */
54
+ staleCoverageThreshold?: TimeSpan;
49
55
  }
50
56
 
51
57
  export const DEFAULT_STATIC_PROPS: Required<StaticProps> = {
52
58
  instrumentation: alamos.NOOP,
53
59
  transform: IDENTITY_TRANSFORM,
54
60
  staleEntryThreshold: TimeSpan.seconds(20),
61
+ staleCoverageThreshold: TimeSpan.minutes(10),
55
62
  };
56
63
 
57
64
  interface CacheEntry {
@@ -61,6 +68,13 @@ interface CacheEntry {
61
68
  addedAt: TimeStamp;
62
69
  }
63
70
 
71
+ interface CoveredRange {
72
+ /** The fetched span, held even when the fetch returned no samples. */
73
+ range: TimeRange;
74
+ /** When the fetch completed. Coverage answers gaps until it goes stale. */
75
+ addedAt: TimeStamp;
76
+ }
77
+
64
78
  /**
65
79
  * A cache for historical channel data that will not be modified after it is written.
66
80
  * Fetched and streamed entries are held apart because they measure position in
@@ -71,6 +85,9 @@ interface CacheEntry {
71
85
  export class Static {
72
86
  private fetched: CacheEntry[] = [];
73
87
  private streamed: CacheEntry[] = [];
88
+ // Sorted, non-overlapping spans already fetched, kept apart from the entries so a
89
+ // span that returned no samples still counts as answered and is not refetched.
90
+ private covered: CoveredRange[] = [];
74
91
  private readonly props: Required<StaticProps>;
75
92
 
76
93
  constructor(props: StaticProps) {
@@ -78,8 +95,14 @@ export class Static {
78
95
  instrumentation = DEFAULT_STATIC_PROPS.instrumentation,
79
96
  transform = DEFAULT_STATIC_PROPS.transform,
80
97
  staleEntryThreshold = DEFAULT_STATIC_PROPS.staleEntryThreshold,
98
+ staleCoverageThreshold = DEFAULT_STATIC_PROPS.staleCoverageThreshold,
81
99
  } = props;
82
- this.props = { instrumentation, transform, staleEntryThreshold };
100
+ this.props = {
101
+ instrumentation,
102
+ transform,
103
+ staleEntryThreshold,
104
+ staleCoverageThreshold,
105
+ };
83
106
  }
84
107
 
85
108
  /**
@@ -98,6 +121,36 @@ export class Static {
98
121
  this.repairIntegrity(series, entries);
99
122
  }
100
123
 
124
+ /**
125
+ * Records tr as fetched, merging it into the covered set. A covered span counts
126
+ * as answered even when empty, so it is refetched once it goes stale, not per read.
127
+ */
128
+ markFetched(tr: TimeRange): void {
129
+ if (!tr.isValid || tr.span.isZero) return;
130
+ let { start, end } = tr;
131
+ // Live merges keep the oldest stamp so a rolling read cannot refresh its own
132
+ // coverage forever. Stale records drop, so a refetch restarts their clock.
133
+ let addedAt = TimeStamp.now();
134
+ const keep: CoveredRange[] = [];
135
+ for (const c of this.covered) {
136
+ if (!this.isLive(c)) continue;
137
+ if (c.range.end.before(start) || c.range.start.after(end)) keep.push(c);
138
+ else {
139
+ if (c.range.start.before(start)) start = c.range.start;
140
+ if (c.range.end.after(end)) end = c.range.end;
141
+ if (c.addedAt.before(addedAt)) addedAt = c.addedAt;
142
+ }
143
+ }
144
+ keep.push({ range: new TimeRange(start, end), addedAt });
145
+ keep.sort((a, b) => TimeRange.sort(a.range, b.range));
146
+ this.covered = keep;
147
+ }
148
+
149
+ // Coverage answers gaps only until it goes stale. gc merely prunes dead records.
150
+ private isLive(c: CoveredRange): boolean {
151
+ return TimeStamp.since(c.addedAt).lessThan(this.props.staleCoverageThreshold);
152
+ }
153
+
101
154
  // Containment, not overlap: a fetch stamped wider than the data it returned
102
155
  // (an uncommitted tail) must not evict streamed samples it did not replace.
103
156
  private evictStreamed(written: MultiSeries): void {
@@ -125,11 +178,12 @@ export class Static {
125
178
  entries.filter((e) => e.data.timeRange.overlapsWith(tr)).map((e) => e.data);
126
179
  const fetched = overlapping(this.fetched);
127
180
  const series = [...fetched, ...overlapping(this.streamed)];
128
- if (fetched.length === 0) return { series: new MultiSeries(series), gaps: [tr] };
181
+ if (fetched.length === 0)
182
+ return { series: new MultiSeries(series), gaps: this.subtractCovered(tr) };
129
183
  const gaps: TimeRange[] = [];
130
184
  const pushGap = (start: TimeStamp, end: TimeStamp): void => {
131
185
  const gap = new TimeRange(start, end);
132
- if (gap.isValid && !gap.span.isZero) gaps.push(gap);
186
+ if (gap.isValid && !gap.span.isZero) gaps.push(...this.subtractCovered(gap));
133
187
  };
134
188
  pushGap(tr.start, fetched[0].timeRange.start);
135
189
  for (let i = 1; i < fetched.length; i++)
@@ -138,6 +192,29 @@ export class Static {
138
192
  return { series: new MultiSeries(series), gaps };
139
193
  }
140
194
 
195
+ // Removes the covered portions of gap, returning the still-unanswered remainder.
196
+ private subtractCovered(gap: TimeRange): TimeRange[] {
197
+ let remaining = [gap];
198
+ for (const c of this.covered) {
199
+ if (!this.isLive(c)) continue;
200
+ const { range } = c;
201
+ const next: TimeRange[] = [];
202
+ for (const r of remaining) {
203
+ if (!range.overlapsWith(r)) {
204
+ next.push(r);
205
+ continue;
206
+ }
207
+ const before = new TimeRange(r.start, range.start);
208
+ const after = new TimeRange(range.end, r.end);
209
+ if (before.isValid && !before.span.isZero) next.push(before);
210
+ if (after.isValid && !after.span.isZero) next.push(after);
211
+ }
212
+ remaining = next;
213
+ if (remaining.length === 0) break;
214
+ }
215
+ return remaining;
216
+ }
217
+
141
218
  /**
142
219
  * Garbage collects the cache, removing any stale entries.
143
220
  * @returns metrics about the garbage collection.
@@ -158,6 +235,11 @@ export class Static {
158
235
  });
159
236
  this.fetched = collect(this.fetched);
160
237
  this.streamed = collect(this.streamed);
238
+ // The client cannot tell "no data yet" from "no data ever" (backfill is legal),
239
+ // so stale coverage re-opens an empty span to one cheap fetch per staleness
240
+ // window instead of one per read (space-heater mode). Series purging above is not
241
+ // affected.
242
+ this.covered = this.covered.filter((c) => this.isLive(c));
161
243
  return res;
162
244
  }
163
245
 
@@ -165,6 +247,7 @@ export class Static {
165
247
  close(): void {
166
248
  this.fetched = [];
167
249
  this.streamed = [];
250
+ this.covered = [];
168
251
  }
169
252
 
170
253
  private writeOne(series: Series, entries: CacheEntry[]): void {
@@ -70,6 +70,13 @@ export class Unary {
70
70
  this.static.write(series);
71
71
  }
72
72
 
73
+ /** Records tr as fetched so reads stop reporting it as a gap even when it holds no
74
+ * samples. Call after a fetch for tr completes. */
75
+ markFetched(tr: TimeRange): void {
76
+ this.checkOpen("markFetched");
77
+ this.static.markFetched(tr);
78
+ }
79
+
73
80
  /**
74
81
  * Reads cached data overlapping the given time range. The result includes the live
75
82
  * leading buffer when the data it actually holds overlaps the range, judged by the
@@ -52,6 +52,7 @@ export class Feed {
52
52
  dynamicBufferSize,
53
53
  gcInterval,
54
54
  staleEntryThreshold,
55
+ staleCoverageThreshold,
55
56
  removalDelay,
56
57
  breaker,
57
58
  batchDebounce,
@@ -62,6 +63,7 @@ export class Feed {
62
63
  dynamicBufferSize,
63
64
  gcInterval,
64
65
  staleEntryThreshold,
66
+ staleCoverageThreshold,
65
67
  instrumentation: instrumentation?.child("cache"),
66
68
  });
67
69
  this.reader = new Reader({
@@ -146,9 +146,9 @@ const authorityParamsZ = z
146
146
  control.authorityZ.optional(),
147
147
  ])
148
148
  .transform(([value, authority]) => {
149
- if (control.authorityZ.safeParse(value).success)
150
- return { keys: [], authorities: [value as control.Authority] };
151
- if (keyZ.or(nameZ).safeParse(value).success) {
149
+ if (z.validate(control.authorityZ, value))
150
+ return { keys: [], authorities: [value] };
151
+ if (z.validate(keyZ.or(nameZ), value)) {
152
152
  if (authority == null)
153
153
  throw new Error(
154
154
  "authority is required when setting authority for a single channel",
@@ -158,8 +158,7 @@ const authorityParamsZ = z
158
158
  authorities: [authority],
159
159
  };
160
160
  }
161
- const oValue = value as Record<channel.Key | channel.Name, control.Authority>;
162
- return { keys: Object.keys(oValue), authorities: Object.values(oValue) };
161
+ return { keys: Object.keys(value), authorities: Object.values(value) };
163
162
  });
164
163
 
165
164
  /**
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@
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 "zod/compile";
11
+
10
12
  export { access } from "@/access";
11
13
  export type { Policy } from "@/access/policy/types.gen";
12
14
  export type { Role } from "@/access/role/types.gen";
@@ -16,6 +16,7 @@ import { actions } from "@/actions";
16
16
  import { channel } from "@/channel";
17
17
  import {
18
18
  axisKeyZ,
19
+ customRangeZ,
19
20
  downsampleModeZ,
20
21
  keyZ,
21
22
  type LinePlot,
@@ -154,6 +155,16 @@ export const setRangesPayloadZ = z.object({
154
155
 
155
156
  export type SetRangesPayload = z.infer<typeof setRangesPayloadZ>;
156
157
 
158
+ /**
159
+ * SetCustomRange sets the window the "custom" range key resolves to. A null custom
160
+ * clears the window.
161
+ */
162
+ export const setCustomRangePayloadZ = z.object({
163
+ custom: zod.nullToUndefined(customRangeZ),
164
+ });
165
+
166
+ export type SetCustomRangePayload = z.infer<typeof setCustomRangePayloadZ>;
167
+
157
168
  /** SetAxisLabel sets the label rendered along the axis identified by key. */
158
169
  export const setAxisLabelPayloadZ = z.object({
159
170
  key: axisKeyZ,
@@ -380,6 +391,10 @@ export const actionZ = z.discriminatedUnion("type", [
380
391
  z.object({ type: z.literal("add_range"), addRange: addRangePayloadZ }),
381
392
  z.object({ type: z.literal("remove_range"), removeRange: removeRangePayloadZ }),
382
393
  z.object({ type: z.literal("set_ranges"), setRanges: setRangesPayloadZ }),
394
+ z.object({
395
+ type: z.literal("set_custom_range"),
396
+ setCustomRange: setCustomRangePayloadZ,
397
+ }),
383
398
  z.object({ type: z.literal("set_axis_label"), setAxisLabel: setAxisLabelPayloadZ }),
384
399
  z.object({
385
400
  type: z.literal("set_axis_label_direction"),
@@ -536,6 +551,15 @@ export const setRanges = (payload: z.input<typeof setRangesPayloadZ>): Action =>
536
551
  }),
537
552
  });
538
553
 
554
+ export const setCustomRange = (
555
+ payload: z.input<typeof setCustomRangePayloadZ>,
556
+ ): Action => ({
557
+ type: "set_custom_range",
558
+ setCustomRange: zod.parse(setCustomRangePayloadZ, payload, {
559
+ label: "line plot set_custom_range action payload",
560
+ }),
561
+ });
562
+
539
563
  export const setAxisLabel = (
540
564
  payload: z.input<typeof setAxisLabelPayloadZ>,
541
565
  ): Action => ({
@@ -748,6 +772,10 @@ export interface Handlers {
748
772
  addRange: (state: Draft<LinePlot>, payload: AddRangePayload) => HandlerResult;
749
773
  removeRange: (state: Draft<LinePlot>, payload: RemoveRangePayload) => HandlerResult;
750
774
  setRanges: (state: Draft<LinePlot>, payload: SetRangesPayload) => HandlerResult;
775
+ setCustomRange: (
776
+ state: Draft<LinePlot>,
777
+ payload: SetCustomRangePayload,
778
+ ) => HandlerResult;
751
779
  setAxisLabel: (state: Draft<LinePlot>, payload: SetAxisLabelPayload) => HandlerResult;
752
780
  setAxisLabelDirection: (
753
781
  state: Draft<LinePlot>,
@@ -830,6 +858,8 @@ export const createReduceAll = (handlers: Handlers) =>
830
858
  return handlers.removeRange(state, action.removeRange);
831
859
  case "set_ranges":
832
860
  return handlers.setRanges(state, action.setRanges);
861
+ case "set_custom_range":
862
+ return handlers.setCustomRange(state, action.setCustomRange);
833
863
  case "set_axis_label":
834
864
  return handlers.setAxisLabel(state, action.setAxisLabel);
835
865
  case "set_axis_label_direction":
@@ -25,6 +25,7 @@ import {
25
25
  setAxisTickSpacing,
26
26
  setAxisType,
27
27
  setChannels,
28
+ setCustomRange,
28
29
  setLegendHidden,
29
30
  setLegendPosition,
30
31
  setLine,
@@ -261,6 +262,19 @@ const handlers: Handlers = {
261
262
  };
262
263
  },
263
264
 
265
+ setCustomRange: (state, payload) => {
266
+ const old = state.ranges.custom;
267
+ state.ranges.custom = payload.custom;
268
+ return {
269
+ inverse: [
270
+ setCustomRange({
271
+ custom: old === undefined ? undefined : actions.snapshotDraft(old),
272
+ }),
273
+ ],
274
+ targets: ["range:custom"],
275
+ };
276
+ },
277
+
264
278
  setAxisLabel: (state, payload) => {
265
279
  const axis = state.axes[payload.key];
266
280
  const inverse = [setAxisLabel({ key: payload.key, label: axis.label })];
@@ -9,7 +9,7 @@
9
9
 
10
10
  // Code generated by Oracle. DO NOT EDIT.
11
11
 
12
- import { color, spatial, text, uuid } from "@synnaxlabs/x";
12
+ import { color, spatial, telem, text, uuid, zod } from "@synnaxlabs/x";
13
13
  import { z } from "zod";
14
14
 
15
15
  import { channel } from "@/channel";
@@ -81,26 +81,6 @@ export const channelsZ = z.object({
81
81
  });
82
82
  export interface Channels extends z.infer<typeof channelsZ> {}
83
83
 
84
- /** Ranges binds range keys to each x-axis. */
85
- export const rangesZ = z.object({
86
- /**
87
- * x1 are the range keys plotted against the x1 axis. Range keys are opaque strings
88
- * rather than UUIDs because the console layers synthetic rolling-window ranges (e.g.
89
- * "recent", "rolling1m") alongside persisted ranges; the server stores whatever the
90
- * client sends.
91
- */
92
- x1: z
93
- .string()
94
- .array()
95
- .default(() => []),
96
- /** x2 are the range keys plotted against the x2 axis. */
97
- x2: z
98
- .string()
99
- .array()
100
- .default(() => []),
101
- });
102
- export interface Ranges extends z.infer<typeof rangesZ> {}
103
-
104
84
  /**
105
85
  * ManualBounds controls whether an axis uses a manually-set bound on each side
106
86
  * independently. When a side is false (the default), the corresponding entry in
@@ -175,6 +155,42 @@ export interface Rule extends z.infer<typeof ruleZ> {}
175
155
  export const keyZ = z.uuid();
176
156
  export type Key = z.infer<typeof keyZ>;
177
157
 
158
+ /** DynamicCustomRange is a rolling window. */
159
+ export const dynamicCustomRangeZ = z.object({
160
+ variant: z.literal("dynamic"),
161
+ /** span is the span of the window, which ends now. */
162
+ span: telem.numericTimeSpanZ.default(0),
163
+ });
164
+ export interface DynamicCustomRange extends z.infer<typeof dynamicCustomRangeZ> {}
165
+
166
+ /** StaticCustomRange is a fixed window. */
167
+ export const staticCustomRangeZ = z.object({
168
+ variant: z.literal("static"),
169
+ /** start is the inclusive start of the fixed window. */
170
+ start: telem.stringTimeStampZ,
171
+ /** end is the exclusive end of the fixed window. */
172
+ end: telem.stringTimeStampZ,
173
+ });
174
+ export interface StaticCustomRange extends z.infer<typeof staticCustomRangeZ> {}
175
+
176
+ export const CUSTOM_RANGE_TYPES = ["dynamic", "static"] as const;
177
+ export const customRangeTypeZ = z.enum(CUSTOM_RANGE_TYPES);
178
+ export type CustomRangeType = z.infer<typeof customRangeTypeZ>;
179
+
180
+ /** CustomRange is the window a plot's synthetic "custom" range key resolves to. */
181
+ export const customRangeZ = z.discriminatedUnion("variant", [
182
+ dynamicCustomRangeZ,
183
+ staticCustomRangeZ,
184
+ ]);
185
+ export type CustomRange = DynamicCustomRange | StaticCustomRange;
186
+
187
+ export const CUSTOM_RANGE_SCHEMAS: {
188
+ [K in CustomRangeType]: z.ZodType<Extract<CustomRange, { variant: K }>>;
189
+ } = {
190
+ dynamic: dynamicCustomRangeZ,
191
+ static: staticCustomRangeZ,
192
+ };
193
+
178
194
  /** Axis is the configuration for a single plot axis. */
179
195
  export const axisZ = z.object({
180
196
  /** key identifies which of the six axes this configuration applies to. */
@@ -203,6 +219,28 @@ export const axisZ = z.object({
203
219
  });
204
220
  export interface Axis extends z.infer<typeof axisZ> {}
205
221
 
222
+ /** Ranges binds range keys to each x-axis. */
223
+ export const rangesZ = z.object({
224
+ /**
225
+ * x1 are the range keys plotted against the x1 axis. Range keys are opaque strings
226
+ * rather than UUIDs because the console layers synthetic rolling-window ranges (e.g.
227
+ * "recent", "rolling1m") alongside persisted ranges; the server stores whatever the
228
+ * client sends.
229
+ */
230
+ x1: z
231
+ .string()
232
+ .array()
233
+ .default(() => []),
234
+ /** x2 are the range keys plotted against the x2 axis. */
235
+ x2: z
236
+ .string()
237
+ .array()
238
+ .default(() => []),
239
+ /** custom is the window the "custom" range key resolves to. */
240
+ custom: zod.nullToUndefined(customRangeZ),
241
+ });
242
+ export interface Ranges extends z.infer<typeof rangesZ> {}
243
+
206
244
  /** Axes bundles configuration for all six fixed plot axes. */
207
245
  export const axesZ = z.object({
208
246
  /** x1 is the x1 axis configuration. */
@@ -263,8 +263,6 @@ export class Client extends query.Retriever<typeof retrieveMultiParamsZ, Key, Pa
263
263
  );
264
264
  }
265
265
 
266
- async delete(key: Key, opts?: query.WriteOptions): Promise<void>;
267
- async delete(keys: Key[], opts?: query.WriteOptions): Promise<void>;
268
266
  async delete(keys: Key | Key[], opts: query.WriteOptions = {}): Promise<void> {
269
267
  const keysArr = array.toArray(keys);
270
268
  const drop = () => [
@@ -182,8 +182,6 @@ export class Client extends query.Retriever<typeof retrieveMultiParamsZ, Key, Pr
182
182
  this.mergeThrough(key, { layout });
183
183
  }
184
184
 
185
- async delete(key: Key, opts?: query.WriteOptions): Promise<void>;
186
- async delete(keys: Key[], opts?: query.WriteOptions): Promise<void>;
187
185
  async delete(keys: Key | Key[], opts: query.WriteOptions = {}): Promise<void> {
188
186
  const keysArr = array.toArray(keys);
189
187
  const drop = () => [
@@ -29,13 +29,11 @@ export class LookupIndex<
29
29
  this.extract = extract;
30
30
  }
31
31
 
32
- /** Returns the entries whose extracted index value equals the given value. */
33
- get(value: IndexValue): Value[];
34
32
  /**
35
- * Returns the entries matching any of the given values, in the order of the
36
- * values and each entry once.
33
+ * Returns the entries whose extracted index value equals the given value. Given an
34
+ * array, returns the entries matching any of the values, in the order of the values
35
+ * and each entry once.
37
36
  */
38
- get(values: IndexValue[]): Value[];
39
37
  get(value: IndexValue | IndexValue[]): Value[] {
40
38
  if (Array.isArray(value))
41
39
  return Array.from(new Set(value)).flatMap((v) => this.get(v));
@@ -312,11 +312,10 @@ export class Table<
312
312
  return this.set(arr);
313
313
  }
314
314
 
315
- /** Returns every entry in the table. */
316
- get(): Value[];
315
+ /** Returns every entry in the table, or every entry the filter accepts. */
316
+ get(filter?: (value: Value) => boolean): Value[];
317
317
  get(key: Key): Value | undefined;
318
318
  get(keys: Key[]): Value[];
319
- get(filter: (value: Value) => boolean): Value[];
320
319
  get(keys?: Key | Key[] | ((value: Value) => boolean)): Value | Value[] | undefined {
321
320
  if (keys === undefined) return Array.from(this.entries.values());
322
321
  if (typeof keys === "function")
@@ -444,7 +444,7 @@ export class Client extends query.Retriever<
444
444
  table: composed,
445
445
  fetch: async (query) => [(await this.fetchSingle(query)).key],
446
446
  compose: ([record]) => record,
447
- keyOf: (query) => (keyZ.safeParse(query).success ? query : null),
447
+ keyOf: (query) => (z.validate(keyZ, query) ? query : null),
448
448
  matches: (r, query) => r.key === query || r.name === query,
449
449
  single: true,
450
450
  });
@@ -576,9 +576,9 @@ export class Client extends query.Retriever<
576
576
  }
577
577
 
578
578
  async retrieve(params: Key | Name): Promise<Range>;
579
- async retrieve(params: Key[] | Name[]): Promise<Range[]>;
580
- async retrieve(params: CrudeTimeRange): Promise<Range[]>;
581
- async retrieve(params: RetrieveRequest): Promise<Range[]>;
579
+ async retrieve(
580
+ params: Key[] | Name[] | CrudeTimeRange | RetrieveRequest,
581
+ ): Promise<Range[]>;
582
582
  async retrieve(params: RetrieveParams): Promise<Range | Range[]> {
583
583
  // The branches narrow params onto different base overloads.
584
584
  if (typeof params === "string") return await super.retrieve(params);
@@ -694,7 +694,7 @@ export class Client extends query.Retriever<
694
694
  private async fetchSingle(query: Key | Name): Promise<Range> {
695
695
  const cached = this.store.get(query);
696
696
  if (cached != null) return this.composeOne(cached);
697
- if (keyZ.safeParse(query).success) {
697
+ if (z.validate(keyZ, query)) {
698
698
  const ranges = await this.store.retrieve([query]);
699
699
  checkForMultipleOrNoResults("Range", query, ranges, true);
700
700
  return this.composeOne(ranges[0]);
@@ -23,6 +23,9 @@ import {
23
23
  setNodePosition,
24
24
  } from "@/schematic/actions.gen";
25
25
 
26
+ // Must match the pluto groupBox symbol's config variant.
27
+ const GROUP_BOX_VARIANT = "groupBox";
28
+
26
29
  const handlers: Handlers = {
27
30
  create: (state, payload) => {
28
31
  Object.assign(state, payload.schematic);
@@ -83,16 +86,24 @@ const handlers: Handlers = {
83
86
  oldConfigRaw != null ? actions.snapshotDraft(oldConfigRaw) : undefined;
84
87
  state.nodes.splice(idx, 1);
85
88
  delete state.configs[payload.key];
86
- return {
87
- inverse: [
88
- setNode(
89
- oldConfig != null
90
- ? { node: oldNode, config: oldConfig }
91
- : { node: oldNode, config: undefined },
92
- ),
93
- ],
94
- targets: [payload.key],
95
- };
89
+ const inverse: Action[] = [
90
+ setNode(
91
+ oldConfig != null
92
+ ? { node: oldNode, config: oldConfig }
93
+ : { node: oldNode, config: undefined },
94
+ ),
95
+ ];
96
+ const targets = [payload.key];
97
+ for (const [key, config] of Object.entries(state.configs)) {
98
+ if (config?.variant !== GROUP_BOX_VARIANT) continue;
99
+ const members = config.members;
100
+ if (!Array.isArray(members) || !members.includes(payload.key)) continue;
101
+ const oldMembers = actions.snapshotDraft(members);
102
+ config.members = members.filter((m) => m !== payload.key);
103
+ inverse.push(setConfig({ key, config: { members: oldMembers } }));
104
+ targets.push(key);
105
+ }
106
+ return { inverse, targets };
96
107
  },
97
108
  addEdge: (state, payload) => {
98
109
  if (state.edges.some((e) => e.key === payload.edge.key))
@@ -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.