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