@synnaxlabs/client 0.15.2 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@
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 { type UnaryClient } from "@synnaxlabs/freighter";
10
11
  import {
11
12
  DataType,
12
13
  Rate,
@@ -16,7 +17,7 @@ import {
16
17
  type TimeRange,
17
18
  type AsyncTermSearcher,
18
19
  toArray,
19
- type CrudeTimeSpan,
20
+ type CrudeTimeStamp,
20
21
  } from "@synnaxlabs/x";
21
22
 
22
23
  import { type Creator } from "@/channel/creator";
@@ -28,24 +29,65 @@ import {
28
29
  payload,
29
30
  type NewPayload,
30
31
  } from "@/channel/payload";
31
- import { analyzeParams, type Retriever } from "@/channel/retriever";
32
- import { QueryError } from "@/errors";
32
+ import {
33
+ analyzeParams,
34
+ CacheRetriever,
35
+ ClusterRetriever,
36
+ DebouncedBatchRetriever,
37
+ type Retriever,
38
+ } from "@/channel/retriever";
39
+ import { MultipleResultsError, NoResultsError, ValidationError } from "@/errors";
33
40
  import { type framer } from "@/framer";
34
41
 
35
42
  /**
36
- * Represents a Channel in a Synnax database. It should not be instantiated
37
- * directly, but rather created or retrieved from a {@link Client}.
43
+ * Represents a Channel in a Synnax database. Typically, channels should not be
44
+ * instantiated directly, but instead created via the `.channels.create` or retrieved
45
+ * via the `.channels.retrieve` method on a Synnax client.
46
+ *
47
+ * Please refer to the [Synnax documentation](https://docs.synnaxlabs.com) for detailed
48
+ * information on what channels are and how to use them.
38
49
  */
39
50
  export class Channel {
40
51
  private readonly _frameClient: framer.Client | null;
41
- key: Key;
42
- name: string;
43
- rate: Rate;
44
- dataType: DataType;
45
- leaseholder: number;
46
- index: Key;
47
- isIndex: boolean;
48
- alias: string | undefined;
52
+ /**
53
+ * A unique key identifying the channel in the Synnax database. This key is
54
+ * automatically assigned by Synnax.
55
+ */
56
+ readonly key: Key;
57
+ /**
58
+ * A human-readable name for the channel. This name is not guaranteed to be
59
+ * unique.
60
+ */
61
+ readonly name: string;
62
+ /**
63
+ * The rate at which the channel samples telemetry. This only applies to fixed rate
64
+ * channels, and will be 0 if the channel is indexed.
65
+ */
66
+ readonly rate: Rate;
67
+ /**
68
+ * The data type of the channel.
69
+ */
70
+ readonly dataType: DataType;
71
+ /**
72
+ * The key of the node in the Synnax cluster that holds the 'lease' over the channel
73
+ * i.e. it's the only node in the cluster allowed to accept writes to the channel. This
74
+ * property is mostly for internal use.
75
+ */
76
+ readonly leaseholder: number;
77
+ /**
78
+ * The key of the index channel that this channel is associated with i.e. the channel
79
+ * that stores its timestamps.
80
+ */
81
+ readonly index: Key;
82
+ /**
83
+ * This is set to true if the channel is an index channel, and false otherwise.
84
+ */
85
+ readonly isIndex: boolean;
86
+ /**
87
+ * An alias for the channel under a specific range. This parameter is unstable and
88
+ * should not be relied upon in the current version of Synnax.
89
+ */
90
+ readonly alias: string | undefined;
49
91
 
50
92
  constructor({
51
93
  dataType,
@@ -74,10 +116,15 @@ export class Channel {
74
116
 
75
117
  private get framer(): framer.Client {
76
118
  if (this._frameClient == null)
77
- throw new Error("cannot read from a channel that has not been created");
119
+ throw new ValidationError("cannot read from a channel that has not been created");
78
120
  return this._frameClient;
79
121
  }
80
122
 
123
+ /**
124
+ * Returns the payload representation of this channel i.e. a pure JS object with
125
+ * all of the channel fields but without any methods. This is used internally for
126
+ * network transportation, but also provided to you as a convenience.
127
+ */
81
128
  get payload(): Payload {
82
129
  return payload.parse({
83
130
  key: this.key,
@@ -107,54 +154,144 @@ export class Channel {
107
154
  * @param start - The starting timestamp of the first sample in data.
108
155
  * @param data - THe telemetry to write to the channel.
109
156
  */
110
- async write(start: CrudeTimeSpan, data: NativeTypedArray): Promise<void> {
157
+ async write(start: CrudeTimeStamp, data: NativeTypedArray): Promise<void> {
111
158
  return await this.framer.write(this.key, start, data);
112
159
  }
113
160
  }
114
161
 
115
162
  /**
116
163
  * The core client class for executing channel operations against a Synnax
117
- * cluster.
164
+ * cluster. This class should not be instantiated directly, and instead should be used
165
+ * through the `channels` property of an {@link Synnax} client.
118
166
  */
119
167
  export class Client implements AsyncTermSearcher<string, Key, Channel> {
120
168
  private readonly frameClient: framer.Client;
121
169
  private readonly retriever: Retriever;
122
170
  private readonly creator: Creator;
171
+ private readonly client: UnaryClient;
123
172
 
124
- constructor(segmentClient: framer.Client, retriever: Retriever, creator: Creator) {
125
- this.frameClient = segmentClient;
173
+ constructor(
174
+ frameClient: framer.Client,
175
+ retriever: Retriever,
176
+ client: UnaryClient,
177
+ creator: Creator,
178
+ ) {
179
+ this.frameClient = frameClient;
126
180
  this.retriever = retriever;
181
+ this.client = client;
127
182
  this.creator = creator;
128
183
  }
129
184
 
185
+ /**
186
+ * Creates a single channel with the given properties.
187
+ *
188
+ * @param name - A human-readable name for the channel.
189
+ * @param rate - The rate of the channel. This only applies to fixed rate channels.
190
+ * @param dataType - The data type for the samples stored in the channel.
191
+ * @param index - The key of the index channel that this channel should be associated
192
+ * with. An 'index' channel is a channel that stores timestamps for other channels. Refer
193
+ * to the Synnax documentation (https://docs.synnaxlabs.com) for more information. The
194
+ * index channel must have already been created. This field does not need to be specified
195
+ * if the channel is an index channel, or the channel is a fixed rate channel. If this
196
+ * value is specified, the 'rate' parameter will be ignored.
197
+ * @param isIndex - Set to true if the channel is an index channel, and false otherwise.
198
+ * Index channels must have a data type of `DataType.TIMESTAMP`.
199
+ * @returns the created channel. {@see Channel}
200
+ * @throws {ValidationError} if any of the parameters for creating the channel are
201
+ * invalid.
202
+ *
203
+ * @example
204
+ * ```typescript
205
+ * const indexChannel = await client.channels.create({
206
+ * name: "time",
207
+ * dataType: DataType.TIMESTAMP,
208
+ * isIndex: true,
209
+ * })
210
+ *
211
+ *
212
+ * const dataChannel = await client.channels.create({
213
+ * name: "temperature",
214
+ * dataType: DataType.FLOAT,
215
+ * index: indexChannel.key,
216
+ * });
217
+ * ```
218
+ */
130
219
  async create(channel: NewPayload): Promise<Channel>;
131
220
 
132
- async create(channels: NewPayload[]): Promise<Channel[]>;
133
-
134
221
  /**
135
- * Creates a new channel with the given properties.
222
+ * Creates multiple channels with the given properties. The order of the channels
223
+ * returned is guaranteed to match the order of the channels passed in.
224
+ *
225
+ * @param channels - An array of channel properties to create.
226
+ * For each channel, the following properties should be considered:
227
+ *
228
+ * @param name - A human-readable name for the channel.
229
+ * @param rate - The rate of the channel. This only applies to fixed rate channels. If
230
+ * the 'index' parameter is specified or 'isIndex' is set to true, this parameter will
231
+ * be ignored.
232
+ * @param dataType - The data type for the samples stored in the channel.
233
+ * @param index - The key of the index channel that this channel should be associated
234
+ * with. An 'index' channel is a channel that stores timestamps for other channels. Refer
235
+ * to the Synnax documentation (https://docs.synnaxlabs.com) for more information. The
236
+ * index channel must have already been created. This field does not need to be specified
237
+ * if the channel is an index channel, or the channel is a fixed rate channel. If this
238
+ * value is specified, the 'rate' parameter will be ignored.
239
+ * @param isIndex - Set to true if the channel is an index channel, and false otherwise.
240
+ * Index channels must have a data type of `DataType.TIMESTAMP`.
136
241
  *
137
- * @param props.rate - The rate of the channel.
138
- * @param props.dataType - The data type of the channel.
139
- * @param props.name - The name of the channel. Optional.
140
- * @param props.nodeKey - The ID of the node that holds the lease on the
141
- * channel. If you don't know what this is, don't worry about it.
142
- * @returns The created channel.
242
+ * @param channels
143
243
  */
244
+ async create(channels: NewPayload[]): Promise<Channel[]>;
245
+
144
246
  async create(channels: NewPayload | NewPayload[]): Promise<Channel | Channel[]> {
247
+ console.log("ABC");
145
248
  const single = !Array.isArray(channels);
146
- const res = this.sugar(await this.creator.create(toArray(channels)));
249
+ const payloads = await this.creator.create(toArray(channels));
250
+ const res = this.sugar(payloads);
147
251
  return single ? res[0] : res;
148
252
  }
149
253
 
254
+ /**
255
+ * Retrieves a channel from the database using the given key or name.
256
+ *
257
+ * @param channel - The key or name of the channel to retrieve.
258
+ * @param options - Optional parameters to control the retrieval process.
259
+ * @param options.dataTypes - Limits the query to only channels with the specified data
260
+ * type.
261
+ * @param options.notDataTypes - Limits the query to only channels without the specified
262
+ * data type.
263
+ *
264
+ * @returns The retrieved channel.
265
+ * @throws {NotFoundError} if the channel does not exist in the cluster.
266
+ * @throws {MultipleResultsError} is only thrown if the channel is retrieved by name,
267
+ * and multiple channels with the same name exist in the cluster.
268
+ *
269
+ * @example
270
+ *
271
+ * ```typescript
272
+ * const channel = await client.channels.retrieve("temperature");
273
+ * const channel = await client.channels.retrieve(1);
274
+ * ```
275
+ */
150
276
  async retrieve(channel: KeyOrName, rangeKey?: string): Promise<Channel>;
151
277
 
278
+ /**
279
+ * Retrieves multiple channels from the database using the provided keys or the
280
+ * provided names.
281
+ *
282
+ * @param channels - The keys or the names of the channels to retrieve. Note that
283
+ * this method does not support mixing keys and names in the same call.
284
+ * @param options - Optional parameters to control the retrieval process.
285
+ * @param options.dataTypes - Limits the query to only channels with the specified data
286
+ * type.
287
+ * @param options.notDataTypes - Limits the query to only channels without the specified
288
+ *
289
+ */
152
290
  async retrieve(channels: Params, rangeKey?: string): Promise<Channel[]>;
153
291
 
154
292
  /**
155
293
  * Retrieves a channel from the database using the given parameters.
156
- * @param props.key - The key of the channel to retrieve.
157
- * @param props.name - The name of the channel to retrieve. If props.key is set,
294
+ *
158
295
  * this will be ignored.
159
296
  * @returns The retrieved channel.
160
297
  * @raises {QueryError} If the channel does not exist or if multiple results are returned.
@@ -164,9 +301,10 @@ export class Client implements AsyncTermSearcher<string, Key, Channel> {
164
301
  if (normalized.length === 0) return [];
165
302
  const res = this.sugar(await this.retriever.retrieve(channels, rangeKey));
166
303
  if (!single) return res;
167
- if (res.length === 0) throw new QueryError(`channel matching ${actual} not found`);
304
+ if (res.length === 0)
305
+ throw new NoResultsError(`channel matching ${actual} not found`);
168
306
  if (res.length > 1)
169
- throw new QueryError(`multiple channels matching ${actual} found`);
307
+ throw new MultipleResultsError(`multiple channels matching ${actual} found`);
170
308
  return res[0];
171
309
  }
172
310
 
@@ -182,6 +320,12 @@ export class Client implements AsyncTermSearcher<string, Key, Channel> {
182
320
  return this.sugar(await this.retriever.page(offset, limit, rangeKey));
183
321
  }
184
322
 
323
+ createDebouncedBatchRetriever(deb: number = 10): Retriever {
324
+ return new CacheRetriever(
325
+ new DebouncedBatchRetriever(new ClusterRetriever(this.client), deb),
326
+ );
327
+ }
328
+
185
329
  private sugar(payloads: Payload[]): Channel[] {
186
330
  const { frameClient } = this;
187
331
  return payloads.map((p) => new Channel({ ...p, frameClient }));
@@ -208,4 +352,4 @@ class SearcherUnderRange implements AsyncTermSearcher<string, Key, Channel> {
208
352
  async retrieve(channels: Key[]): Promise<Channel[]> {
209
353
  return await this.client.retrieve(channels, this.rangeKey);
210
354
  }
211
- }
355
+ }
@@ -45,8 +45,8 @@ export type NewPayload = z.input<typeof newPayload>;
45
45
  export const parseChannels = (channels: NewPayload[]): NewPayload[] =>
46
46
  channels.map((channel) => ({
47
47
  name: channel.name,
48
- dataType: new DataType(channel.dataType),
49
- rate: new Rate(channel.rate ?? 0),
48
+ dataType: channel.dataType,
49
+ rate: channel.rate ?? 0,
50
50
  leaseholder: channel.leaseholder,
51
51
  index: channel.index,
52
52
  isIndex: channel.isIndex,
@@ -6,9 +6,8 @@
6
6
  // As of the Change Date specified in that file, in accordance with the Business Source
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
-
10
9
  import type { UnaryClient } from "@synnaxlabs/freighter";
11
- import { toArray } from "@synnaxlabs/x";
10
+ import { debounce, toArray } from "@synnaxlabs/x";
12
11
  import { z } from "zod";
13
12
 
14
13
  import {
@@ -22,6 +21,7 @@ import {
22
21
  type Payload,
23
22
  payload,
24
23
  } from "@/channel/payload";
24
+ import { Mutex } from "async-mutex";
25
25
 
26
26
  const reqZ = z.object({
27
27
  leaseholder: z.number().optional(),
@@ -156,3 +156,63 @@ export const analyzeParams = (channels: Params): ParamAnalysisResult => {
156
156
  actual: channels,
157
157
  } as const as ParamAnalysisResult;
158
158
  };
159
+
160
+ export interface PromiseFns<T> {
161
+ resolve: (value: T) => void;
162
+ reject: (reason?: any) => void;
163
+ }
164
+
165
+ // no interval
166
+ export class DebouncedBatchRetriever implements Retriever {
167
+ private readonly mu = new Mutex();
168
+ private readonly requests = new Map<Keys, PromiseFns<Payload[]>>();
169
+ private readonly wrapped: Retriever;
170
+ private readonly debouncedRun: () => void;
171
+
172
+ constructor(wrapped: Retriever, deb: number) {
173
+ this.wrapped = wrapped;
174
+ this.debouncedRun = debounce(() => {
175
+ void this.run();
176
+ }, deb);
177
+ }
178
+
179
+ async search(term: string, rangeKey?: string): Promise<Payload[]> {
180
+ return await this.wrapped.search(term, rangeKey);
181
+ }
182
+
183
+ async page(offset: number, limit: number, rangeKey?: string): Promise<Payload[]> {
184
+ return await this.wrapped.page(offset, limit, rangeKey);
185
+ }
186
+
187
+ async retrieve(channels: Params): Promise<Payload[]> {
188
+ const { normalized, variant } = analyzeParams(channels);
189
+ // Bypass on name fetches for now.
190
+ if (variant === "names")
191
+ return await this.wrapped.retrieve(normalized);
192
+ // eslint-disable-next-line @typescript-eslint/promise-function-async
193
+ const a = new Promise<Payload[]>((resolve, reject) => {
194
+ void this.mu.runExclusive(() => {
195
+ this.requests.set(normalized, { resolve, reject });
196
+ this.debouncedRun();
197
+ });
198
+ });
199
+ return await a;
200
+ }
201
+
202
+ async run(): Promise<void> {
203
+ await this.mu.runExclusive(async () => {
204
+ const allKeys = new Set<Key>();
205
+ this.requests.forEach((_, keys) => keys.forEach((k) => allKeys.add(k)));
206
+ try {
207
+ const channels = await this.wrapped.retrieve(Array.from(allKeys));
208
+ this.requests.forEach((fns, keys) =>
209
+ fns.resolve(channels.filter((c) => keys.includes(c.key))),
210
+ );
211
+ } catch (e) {
212
+ this.requests.forEach((fns) => fns.reject(e));
213
+ } finally {
214
+ this.requests.clear();
215
+ }
216
+ });
217
+ }
218
+ }
package/src/client.ts CHANGED
@@ -15,16 +15,15 @@ import { channel } from "@/channel";
15
15
  import { connection } from "@/connection";
16
16
  import { errorsMiddleware } from "@/errors";
17
17
  import { framer } from "@/framer";
18
+ import { hardware } from "@/hardware";
19
+ import { device } from "@/hardware/device";
20
+ import { rack } from "@/hardware/rack";
21
+ import { task } from "@/hardware/task";
18
22
  import { label } from "@/label";
19
23
  import { ontology } from "@/ontology";
20
24
  import { ranger } from "@/ranger";
21
25
  import { Transport } from "@/transport";
22
26
  import { workspace } from "@/workspace";
23
- import { hardware } from "./hardware";
24
- import { device } from "./hardware/device";
25
- import { rack } from "./hardware/rack";
26
- import { task } from "./hardware/task";
27
- import { randomUUID } from "crypto";
28
27
 
29
28
  export const synnaxPropsZ = z.object({
30
29
  host: z.string().min(1),
@@ -97,7 +96,12 @@ export default class Synnax {
97
96
  );
98
97
  const chCreator = new channel.Creator(this.transport.unary);
99
98
  this.telem = new framer.Client(this.transport.stream, chRetriever);
100
- this.channels = new channel.Client(this.telem, chRetriever, chCreator);
99
+ this.channels = new channel.Client(
100
+ this.telem,
101
+ chRetriever,
102
+ this.transport.unary,
103
+ chCreator,
104
+ );
101
105
  this.connectivity = new connection.Checker(
102
106
  this.transport.unary,
103
107
  connectivityPollFrequency,
package/src/errors.ts CHANGED
@@ -95,6 +95,10 @@ export class UnexpectedError extends BaseError {
95
95
  */
96
96
  export class QueryError extends BaseError {}
97
97
 
98
+ export class NoResultsError extends QueryError {}
99
+
100
+ export class MultipleResultsError extends QueryError {}
101
+
98
102
  /**
99
103
  * RouteError is raised when a routing error occurs.
100
104
  */
@@ -27,6 +27,7 @@ describe("Streamer", () => {
27
27
  test("happy path", async () => {
28
28
  const ch = await newChannel();
29
29
  const streamer = await client.telem.newStreamer(ch.key);
30
+ await new Promise((resolve) => setTimeout(resolve, 100));
30
31
  const writer = await client.telem.newWriter({
31
32
  start: TimeStamp.now(),
32
33
  channels: ch.key,
@@ -21,7 +21,7 @@ const retrieveReqZ = z.object({
21
21
  });
22
22
 
23
23
  const rerieveResS = z.object({
24
- tasks: taskZ.array(),
24
+ tasks: z.union([taskZ.array(), z.null().transform(() => [])]),
25
25
  });
26
26
 
27
27
  export type RetrieveRequest = z.infer<typeof retrieveReqZ>;