@synnaxlabs/client 0.54.2 → 0.55.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 (46) hide show
  1. package/.turbo/turbo-build.log +6 -6
  2. package/dist/client.cjs +28 -34
  3. package/dist/client.js +5341 -5193
  4. package/dist/src/arc/arc.spec.d.ts +2 -0
  5. package/dist/src/arc/arc.spec.d.ts.map +1 -0
  6. package/dist/src/arc/graph/types.gen.d.ts +20 -20
  7. package/dist/src/arc/ir/types.gen.d.ts +145 -176
  8. package/dist/src/arc/ir/types.gen.d.ts.map +1 -1
  9. package/dist/src/arc/module/types.gen.d.ts +46 -65
  10. package/dist/src/arc/module/types.gen.d.ts.map +1 -1
  11. package/dist/src/arc/program/types.gen.d.ts +46 -65
  12. package/dist/src/arc/program/types.gen.d.ts.map +1 -1
  13. package/dist/src/arc/types.gen.d.ts +86 -105
  14. package/dist/src/arc/types.gen.d.ts.map +1 -1
  15. package/dist/src/auth/auth.d.ts.map +1 -1
  16. package/dist/src/channel/types.gen.d.ts.map +1 -1
  17. package/dist/src/client.d.ts +5 -0
  18. package/dist/src/client.d.ts.map +1 -1
  19. package/dist/src/connection/checker.d.ts +17 -2
  20. package/dist/src/connection/checker.d.ts.map +1 -1
  21. package/dist/src/control/state.d.ts.map +1 -1
  22. package/dist/src/framer/client.d.ts.map +1 -1
  23. package/dist/src/task/client.d.ts.map +1 -1
  24. package/package.json +10 -10
  25. package/src/arc/arc.spec.ts +44 -0
  26. package/src/arc/ir/types.gen.ts +101 -47
  27. package/src/auth/auth.ts +13 -1
  28. package/src/channel/channel.spec.ts +13 -0
  29. package/src/channel/types.gen.ts +1 -2
  30. package/src/client.ts +3 -0
  31. package/src/connection/checker.ts +44 -5
  32. package/src/connection/connection.spec.ts +67 -2
  33. package/src/control/state.ts +5 -4
  34. package/src/device/device.spec.ts +7 -5
  35. package/src/framer/client.ts +12 -0
  36. package/src/framer/writer.spec.ts +144 -1
  37. package/src/label/label.spec.ts +12 -0
  38. package/src/ontology/ontology.spec.ts +10 -0
  39. package/src/rack/rack.spec.ts +12 -1
  40. package/src/ranger/ranger.spec.ts +12 -0
  41. package/src/schematic/symbol/client.spec.ts +33 -9
  42. package/src/status/status.spec.ts +7 -6
  43. package/src/task/client.ts +7 -9
  44. package/src/task/task.spec.ts +15 -1
  45. package/src/view/view.spec.ts +9 -5
  46. package/src/workspace/workspace.spec.ts +14 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synnaxlabs/client",
3
- "version": "0.54.2",
3
+ "version": "0.55.0",
4
4
  "description": "The Synnax Client Library",
5
5
  "keywords": [
6
6
  "synnax",
@@ -26,19 +26,19 @@
26
26
  "dependencies": {
27
27
  "async-mutex": "^0.5.0",
28
28
  "zod": "^4.3.6",
29
- "@synnaxlabs/freighter": "^0.54.0",
30
- "@synnaxlabs/x": "^0.54.2"
29
+ "@synnaxlabs/x": "^0.55.0",
30
+ "@synnaxlabs/freighter": "^0.55.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@vitest/coverage-v8": "^4.1.2",
34
- "@types/node": "^25.5.0",
35
- "eslint": "^10.1.0",
33
+ "@vitest/coverage-v8": "^4.1.4",
34
+ "@types/node": "^25.6.0",
35
+ "eslint": "^10.2.1",
36
36
  "madge": "^8.0.0",
37
- "typescript": "^6.0.2",
38
- "vite": "^8.0.3",
39
- "vitest": "^4.1.2",
40
- "@synnaxlabs/tsconfig": "^0.0.0",
37
+ "typescript": "^6.0.3",
38
+ "vite": "^8.0.8",
39
+ "vitest": "^4.1.4",
41
40
  "@synnaxlabs/eslint-config": "^0.0.0",
41
+ "@synnaxlabs/tsconfig": "^0.0.0",
42
42
  "@synnaxlabs/vite-plugin": "^0.0.0"
43
43
  },
44
44
  "type": "module",
@@ -0,0 +1,44 @@
1
+ // Copyright 2026 Synnax Labs, Inc.
2
+ //
3
+ // Use of this software is governed by the Business Source License included in the file
4
+ // licenses/BSL.txt.
5
+ //
6
+ // As of the Change Date specified in that file, in accordance with the Business Source
7
+ // License, use of this software will be governed by the Apache License, Version 2.0,
8
+ // included in the file licenses/APL.txt.
9
+
10
+ import { id } from "@synnaxlabs/x";
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import { type arc } from "@/arc";
14
+ import { createTestClient } from "@/testutil/client";
15
+
16
+ const client = createTestClient();
17
+
18
+ const newTextArc = (name: string): arc.New => ({
19
+ name,
20
+ mode: "text",
21
+ graph: {
22
+ nodes: [],
23
+ edges: [],
24
+ viewport: { position: { x: 0, y: 0 }, zoom: 1 },
25
+ functions: [],
26
+ },
27
+ text: { raw: "" },
28
+ });
29
+
30
+ describe("arc", () => {
31
+ describe("retrieve", () => {
32
+ it("should retrieve arcs by search term", async () => {
33
+ const prefix = `searchable-arc-${id.create()}`;
34
+ const names = [`${prefix}-1`, `${prefix}-2`];
35
+ await client.arcs.create(names.map((name) => newTextArc(name)));
36
+ await expect
37
+ .poll(async () => {
38
+ const results = await client.arcs.retrieve({ searchTerm: prefix });
39
+ return results.map((a) => a.name).sort();
40
+ })
41
+ .toEqual(names);
42
+ });
43
+ });
44
+ });
@@ -21,6 +21,20 @@ export enum EdgeKind {
21
21
  }
22
22
  export const edgeKindZ = z.enum(EdgeKind);
23
23
 
24
+ export enum ScopeMode {
25
+ unspecified = 0,
26
+ parallel = 1,
27
+ sequential = 2,
28
+ }
29
+ export const scopeModeZ = z.enum(ScopeMode);
30
+
31
+ export enum Liveness {
32
+ unspecified = 0,
33
+ always = 1,
34
+ gated = 2,
35
+ }
36
+ export const livenessZ = z.enum(Liveness);
37
+
24
38
  /** Handle is a reference to a specific parameter on a specific node in the dataflow graph. */
25
39
  export const handleZ = z.object({
26
40
  /** node is the node identifier. */
@@ -47,13 +61,13 @@ export const nodeZ = z.object({
47
61
  /** type is the function type being instantiated. */
48
62
  type: z.string(),
49
63
  /** config contains configuration parameter values. */
50
- config: types.paramsZ.optional(),
64
+ config: types.paramsZ,
51
65
  /** inputs contains input parameter type signatures. */
52
- inputs: types.paramsZ.optional(),
66
+ inputs: types.paramsZ,
53
67
  /** outputs contains output parameter type signatures. */
54
- outputs: types.paramsZ.optional(),
68
+ outputs: types.paramsZ,
55
69
  /** channels contains channel read/write mappings. */
56
- channels: types.channelsZ.optional(),
70
+ channels: types.channelsZ,
57
71
  });
58
72
  export interface Node extends z.infer<typeof nodeZ> {}
59
73
 
@@ -62,13 +76,10 @@ export const authoritiesZ = z.object({
62
76
  /** default is the default authority for all write channels not explicitly listed. */
63
77
  default: zod.uint8.optional(),
64
78
  /** channels maps channel keys to their specific authority values. */
65
- channels: z.record(z.uint32(), zod.uint8).optional(),
79
+ channels: z.record(z.uint32(), zod.uint8),
66
80
  });
67
81
  export interface Authorities extends z.infer<typeof authoritiesZ> {}
68
82
 
69
- export const stratumZ = array.nullishToEmpty(z.string());
70
- export type Stratum = z.infer<typeof stratumZ>;
71
-
72
83
  /** Edge is a dataflow connection between node parameters in the Arc graph. */
73
84
  export const edgeZ = z.object({
74
85
  /** source is the source node parameter producing data. */
@@ -76,10 +87,22 @@ export const edgeZ = z.object({
76
87
  /** target is the target node parameter consuming data. */
77
88
  target: handleZ,
78
89
  /** kind defines execution semantics for this connection. */
79
- kind: edgeKindZ.optional(),
90
+ kind: edgeKindZ,
80
91
  });
81
92
  export interface Edge extends z.infer<typeof edgeZ> {}
82
93
 
94
+ /** Transition is a declarative state-transition rule on a sequential Scope. */
95
+ export const transitionZ = z.object({
96
+ /** on is the dataflow handle whose truthy value fires this transition. */
97
+ on: handleZ,
98
+ /**
99
+ * targetKey is the sibling step key to activate. Null when the transition
100
+ * exits the scope, yielding to the parent.
101
+ */
102
+ targetKey: z.string().optional(),
103
+ });
104
+ export interface Transition extends z.infer<typeof transitionZ> {}
105
+
83
106
  /**
84
107
  * Function is a function template definition with typed parameters, serving as a
85
108
  * blueprint for node instantiation.
@@ -90,22 +113,19 @@ export const functionZ = z.object({
90
113
  /** body is raw source code for user-defined functions. */
91
114
  body: bodyZ.optional(),
92
115
  /** config contains configuration parameter definitions. */
93
- config: types.paramsZ.optional(),
116
+ config: types.paramsZ,
94
117
  /** inputs contains input parameter definitions. */
95
- inputs: types.paramsZ.optional(),
118
+ inputs: types.paramsZ,
96
119
  /** outputs contains output parameter definitions. */
97
- outputs: types.paramsZ.optional(),
120
+ outputs: types.paramsZ,
98
121
  /** channels contains channel read/write declarations. */
99
- channels: types.channelsZ.optional(),
122
+ channels: types.channelsZ,
100
123
  });
101
124
  export interface Function extends z.infer<typeof functionZ> {}
102
125
 
103
126
  export const nodesZ = array.nullishToEmpty(nodeZ);
104
127
  export type Nodes = z.infer<typeof nodesZ>;
105
128
 
106
- export const strataZ = array.nullishToEmpty(stratumZ);
107
- export type Strata = z.infer<typeof strataZ>;
108
-
109
129
  export const edgesZ = array.nullishToEmpty(edgeZ);
110
130
  export type Edges = z.infer<typeof edgesZ>;
111
131
 
@@ -113,36 +133,65 @@ export const functionsZ = array.nullishToEmpty(functionZ);
113
133
  export type Functions = z.infer<typeof functionsZ>;
114
134
 
115
135
  /**
116
- * Stage is a stage in a sequence state machine, containing active nodes and their
117
- * execution stratification.
136
+ * Member is a tagged union representing a single child of a Scope. Exactly one
137
+ * of nodeKey or scope is set. The member's lookup key (used as the
138
+ * target of `=> name` transitions) is derived from the set variant via
139
+ * Member.key().
118
140
  */
119
- export const stageZ = z.object({
120
- /** key is the stage identifier. */
121
- key: z.string(),
122
- /** nodes contains node keys active in this stage. */
123
- nodes: array.nullishToEmpty(z.string()),
124
- /** strata contains execution stratification for nodes in this stage. */
125
- strata: strataZ,
141
+ export interface Member {
142
+ nodeKey?: string;
143
+ scope?: Scope;
144
+ }
145
+ export const memberZ: z.ZodType<Member> = z.object({
146
+ /**
147
+ * nodeKey is the key of the referenced node in IR.nodes. Null when this
148
+ * member is a nested scope.
149
+ */
150
+ nodeKey: z.string().optional(),
151
+ /** scope is set when this member is a nested scope. */
152
+ get scope() {
153
+ return scopeZ.optional();
154
+ },
126
155
  });
127
- export interface Stage extends z.infer<typeof stageZ> {}
128
156
 
129
157
  /**
130
- * Sequence is a state machine defining ordered stages of execution, where entry point
131
- * is always the first stage.
158
+ * Scope is the unified Layer 2 execution primitive. Parameterized by mode
159
+ * (parallel or sequential) and liveness (always-live or gated). Parallel
160
+ * scopes organize members into strata; sequential scopes run one step
161
+ * at a time and advance via transitions.
132
162
  */
133
- export const sequenceZ = z.object({
134
- /** key is the sequence identifier. */
163
+ export interface Scope {
164
+ key: string;
165
+ mode: ScopeMode;
166
+ liveness: Liveness;
167
+ activation?: Handle;
168
+ strata: Members[];
169
+ steps: Members;
170
+ transitions: Transition[];
171
+ }
172
+ export const scopeZ: z.ZodType<Scope> = z.object({
173
+ /** key is the scope identifier. */
135
174
  key: z.string(),
136
- /** stages contains ordered stages in this sequence. */
137
- stages: array.nullishToEmpty(stageZ),
175
+ /** mode defines whether this scope runs steps in parallel or sequentially. */
176
+ mode: scopeModeZ,
177
+ /** liveness defines whether this scope is continuously active or must be activated. */
178
+ liveness: livenessZ,
179
+ /** activation is the handle whose truthy value activates a gated scope. Unset for always-live scopes. */
180
+ activation: handleZ.optional(),
181
+ /**
182
+ * strata contains stratified execution layers for parallel scopes. Empty
183
+ * for sequential scopes. Stratum N depends only on strata 0 to N-1.
184
+ */
185
+ get strata() {
186
+ return array.nullishToEmpty(membersZ);
187
+ },
188
+ /** steps contains ordered steps for sequential scopes. Empty for parallel scopes. */
189
+ get steps() {
190
+ return membersZ;
191
+ },
192
+ /** transitions contains state-transition rules for sequential scopes. Empty for parallel scopes. */
193
+ transitions: array.nullishToEmpty(transitionZ),
138
194
  });
139
- export interface Sequence extends z.infer<typeof sequenceZ> {}
140
-
141
- export const stagesZ = array.nullishToEmpty(stageZ);
142
- export type Stages = z.infer<typeof stagesZ>;
143
-
144
- export const sequencesZ = array.nullishToEmpty(sequenceZ);
145
- export type Sequences = z.infer<typeof sequencesZ>;
146
195
 
147
196
  /**
148
197
  * IR is the intermediate representation of an Arc program as a dataflow graph
@@ -151,16 +200,21 @@ export type Sequences = z.infer<typeof sequencesZ>;
151
200
  */
152
201
  export const irZ = z.object({
153
202
  /** functions contains function template definitions. */
154
- functions: functionsZ.optional(),
203
+ functions: functionsZ,
155
204
  /** nodes contains node instantiations. */
156
- nodes: nodesZ.optional(),
205
+ nodes: nodesZ,
157
206
  /** edges contains dataflow connections. */
158
- edges: edgesZ.optional(),
159
- /** strata contains execution stratification layers. */
160
- strata: strataZ.optional(),
161
- /** sequences contains state machine definitions. */
162
- sequences: sequencesZ.optional(),
207
+ edges: edgesZ,
163
208
  /** authorities contains the static authority declarations for this program. */
164
- authorities: authoritiesZ.optional(),
209
+ authorities: authoritiesZ,
210
+ /**
211
+ * root is the top-level execution context. The root is always a
212
+ * parallel, always-live Scope whose strata mix module-scope
213
+ * reactive flow with top-level gated scopes.
214
+ */
215
+ root: scopeZ,
165
216
  });
166
217
  export interface IR extends z.infer<typeof irZ> {}
218
+
219
+ export const membersZ = array.nullishToEmpty(memberZ);
220
+ export type Members = z.infer<typeof membersZ>;
package/src/auth/auth.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // included in the file licenses/APL.txt.
9
9
 
10
10
  import { type Middleware, sendRequired, type UnaryClient } from "@synnaxlabs/freighter";
11
+ import { TimeStamp } from "@synnaxlabs/x";
11
12
  import { z } from "zod";
12
13
 
13
14
  import { ExpiredTokenError, InvalidTokenError } from "@/errors";
@@ -16,7 +17,18 @@ import { user } from "@/user";
16
17
  const insecureCredentialsZ = z.object({ username: z.string(), password: z.string() });
17
18
  interface InsecureCredentials extends z.infer<typeof insecureCredentialsZ> {}
18
19
 
19
- const tokenResponseZ = z.object({ token: z.string(), user: user.userZ });
20
+ const clusterInfoZ = z.object({
21
+ clusterKey: z.string(),
22
+ nodeVersion: z.string().optional(),
23
+ nodeKey: z.number().optional(),
24
+ nodeTime: TimeStamp.z,
25
+ });
26
+
27
+ const tokenResponseZ = z.object({
28
+ token: z.string(),
29
+ user: user.userZ,
30
+ clusterInfo: clusterInfoZ.optional(),
31
+ });
20
32
 
21
33
  const LOGIN_ENDPOINT = "/auth/login";
22
34
 
@@ -226,6 +226,19 @@ describe("Channel", () => {
226
226
  async () => await client.channels.retrieve("1-1000"),
227
227
  ).rejects.toThrow(NotFoundError);
228
228
  });
229
+ test("retrieve by search term", async () => {
230
+ const prefix = id.create();
231
+ const names = [`${prefix}_1`, `${prefix}_2`];
232
+ await client.channels.create(
233
+ names.map((name) => ({ name, virtual: true, dataType: DataType.FLOAT32 })),
234
+ );
235
+ await expect
236
+ .poll(async () => {
237
+ const results = await client.channels.retrieve({ searchTerm: prefix });
238
+ return results.map((c) => c.name).sort();
239
+ })
240
+ .toEqual(names);
241
+ });
229
242
  });
230
243
 
231
244
  describe("delete", async () => {
@@ -85,8 +85,7 @@ export const payloadZ = z.object({
85
85
  isIndex: z.boolean(),
86
86
  /**
87
87
  * index is the channel used to index this channel's values, associating
88
- * each value with a timestamp. If zero, the channel's data will be
89
- * indexed using its rate.
88
+ * each value with a timestamp.
90
89
  */
91
90
  index: keyZ,
92
91
  /** alias is an optional alternate name for the channel within a specific context. */
package/src/client.ts CHANGED
@@ -45,6 +45,7 @@ export const synnaxParamsZ = z.object({
45
45
  username: z.string().min(1, "Username is required"),
46
46
  password: z.string().min(1, "Password is required"),
47
47
  connectivityPollFrequency: TimeSpan.z.default(TimeSpan.seconds(30)),
48
+ clockSkewThreshold: TimeSpan.z.default(TimeSpan.seconds(1)),
48
49
  secure: z.boolean().default(false),
49
50
  name: z.string().optional(),
50
51
  retry: breaker.breakerConfigZ.optional(),
@@ -116,6 +117,7 @@ export default class Synnax extends framer.Client {
116
117
  username,
117
118
  password,
118
119
  connectivityPollFrequency,
120
+ clockSkewThreshold,
119
121
  secure,
120
122
  retry: breaker,
121
123
  } = parsedParams;
@@ -141,6 +143,7 @@ export default class Synnax extends framer.Client {
141
143
  connectivityPollFrequency,
142
144
  this.clientVersion,
143
145
  parsedParams.name,
146
+ clockSkewThreshold,
144
147
  );
145
148
  this.control = new control.Client(this);
146
149
  this.ontology = new ontology.Client(this.transport.unary);
@@ -8,7 +8,13 @@
8
8
  // included in the file licenses/APL.txt.
9
9
 
10
10
  import { sendRequired, type UnaryClient } from "@synnaxlabs/freighter";
11
- import { migrate, TimeSpan } from "@synnaxlabs/x";
11
+ import {
12
+ ClockSkewCalculator,
13
+ type CrudeTimeSpan,
14
+ migrate,
15
+ TimeSpan,
16
+ TimeStamp,
17
+ } from "@synnaxlabs/x";
12
18
  import { z } from "zod";
13
19
 
14
20
  export const statusZ = z.enum(["disconnected", "connecting", "connected", "failed"]);
@@ -22,12 +28,15 @@ export const stateZ = z.object({
22
28
  clientVersion: z.string(),
23
29
  clientServerCompatible: z.boolean(),
24
30
  nodeVersion: z.string().optional(),
31
+ clockSkew: TimeSpan.z.default(TimeSpan.ZERO),
32
+ clockSkewExceeded: z.boolean().default(false),
25
33
  });
26
34
  export interface State extends z.infer<typeof stateZ> {}
27
35
 
28
36
  const responseZ = z.object({
29
37
  clusterKey: z.string(),
30
38
  nodeVersion: z.string().optional(),
39
+ nodeTime: TimeStamp.z,
31
40
  });
32
41
  const requestZ = z.void();
33
42
 
@@ -38,6 +47,8 @@ const DEFAULT: State = {
38
47
  message: "Disconnected",
39
48
  clientServerCompatible: false,
40
49
  clientVersion: __VERSION__,
50
+ clockSkew: TimeSpan.ZERO,
51
+ clockSkewExceeded: false,
41
52
  };
42
53
 
43
54
  const createWarning = (
@@ -46,16 +57,16 @@ const createWarning = (
46
57
  clientIsNewer: boolean,
47
58
  ): string => {
48
59
  const toUpgrade = clientIsNewer ? "Core" : "client";
49
- return `Synnax Core version ${nodeVersion != null ? `${nodeVersion} ` : ""}is too ${clientIsNewer ? "old" : "new"} for client version ${clientVersion}.
60
+ return `The Synnax Core version ${nodeVersion != null ? `${nodeVersion} ` : ""}is too ${clientIsNewer ? "old" : "new"} for client version ${clientVersion}.
50
61
  This may cause compatibility issues. We recommend updating the ${toUpgrade}. For more information, see
51
- https://docs.synnaxlabs.com/reference/client/resources/troubleshooting#old-${toUpgrade}-version`;
62
+ https://docs.synnaxlabs.com/reference/client/resources/troubleshooting#old-${toUpgrade.toLowerCase()}-version`;
52
63
  };
53
64
 
54
65
  /** Polls a synnax cluster for connectivity information. */
55
66
  export class Checker {
56
67
  static readonly DEFAULT: State = DEFAULT;
57
68
  private readonly _state: State;
58
- private readonly pollFrequency = TimeSpan.seconds(30);
69
+ private readonly pollFrequency: TimeSpan;
59
70
  private readonly client: UnaryClient;
60
71
  private readonly name?: string;
61
72
  private interval?: NodeJS.Timeout;
@@ -63,6 +74,9 @@ export class Checker {
63
74
  private readonly onChangeHandlers: Array<(state: State) => void> = [];
64
75
  static readonly connectionStateZ = stateZ;
65
76
  private versionWarned = false;
77
+ private readonly skewCalc: ClockSkewCalculator;
78
+ private readonly clockSkewThreshold: TimeSpan;
79
+ private checking = false;
66
80
 
67
81
  /**
68
82
  * @param client - The transport client to use for connectivity checks.
@@ -74,12 +88,15 @@ export class Checker {
74
88
  pollFreq: TimeSpan = TimeSpan.seconds(30),
75
89
  clientVersion: string,
76
90
  name?: string,
91
+ clockSkewThreshold: CrudeTimeSpan = TimeSpan.seconds(1),
77
92
  ) {
78
93
  this._state = { ...DEFAULT };
79
94
  this.client = client;
80
95
  this.pollFrequency = pollFreq;
81
96
  this.clientVersion = clientVersion;
82
97
  this.name = name;
98
+ this.skewCalc = new ClockSkewCalculator();
99
+ this.clockSkewThreshold = new TimeSpan(clockSkewThreshold).abs();
83
100
  void this.check();
84
101
  this.start();
85
102
  }
@@ -95,7 +112,11 @@ export class Checker {
95
112
  */
96
113
  async check(): Promise<State> {
97
114
  const prevStatus = this._state.status;
115
+ const prevSkewExceeded = this._state.clockSkewExceeded;
116
+ const measureSkew = !this.checking;
117
+ this.checking = true;
98
118
  try {
119
+ if (measureSkew) this.skewCalc.start();
99
120
  const res = await sendRequired(
100
121
  this.client,
101
122
  "/connectivity/check",
@@ -103,6 +124,19 @@ export class Checker {
103
124
  requestZ,
104
125
  responseZ,
105
126
  );
127
+ if (measureSkew) {
128
+ this.skewCalc.end(res.nodeTime);
129
+ this._state.clockSkew = this.skewCalc.skew;
130
+ this._state.clockSkewExceeded = this.skewCalc.exceeds(this.clockSkewThreshold);
131
+ if (this._state.clockSkewExceeded) {
132
+ const direction = this.skewCalc.skew.valueOf() > 0n ? "ahead of" : "behind";
133
+ console.warn(
134
+ `Measured excessive clock skew between this host and ` +
135
+ `the Synnax Core. This host is ${direction} the Synnax Core ` +
136
+ `by approximately ${this.skewCalc.skew.abs().toString()}.`,
137
+ );
138
+ }
139
+ }
106
140
  const nodeVersion = res.nodeVersion;
107
141
  const clientVersion = this.clientVersion;
108
142
  const warned = this.versionWarned;
@@ -140,8 +174,13 @@ export class Checker {
140
174
  this._state.status = "failed";
141
175
  this._state.error = err as Error;
142
176
  this._state.message = this.state.error?.message;
177
+ } finally {
178
+ this.checking = false;
143
179
  }
144
- if (this.onChangeHandlers.length > 0 && prevStatus !== this._state.status)
180
+ const changed =
181
+ prevStatus !== this._state.status ||
182
+ prevSkewExceeded !== this._state.clockSkewExceeded;
183
+ if (this.onChangeHandlers.length > 0 && changed)
145
184
  this.onChangeHandlers.forEach((handler) => handler(this.state));
146
185
  return this.state;
147
186
  }
@@ -7,8 +7,9 @@
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 { URL } from "@synnaxlabs/x";
11
- import { describe, expect, it } from "vitest";
10
+ import { type UnaryClient } from "@synnaxlabs/freighter";
11
+ import { TimeSpan, TimeStamp, URL } from "@synnaxlabs/x";
12
+ import { describe, expect, it, vi } from "vitest";
12
13
  import { z } from "zod";
13
14
 
14
15
  import { auth } from "@/auth";
@@ -87,4 +88,68 @@ describe("connectivity", () => {
87
88
  expect(state.clientVersion).toBe("0.0.0");
88
89
  });
89
90
  });
91
+ describe("clock skew", () => {
92
+ const createMockClient = (nodeTime: TimeStamp): UnaryClient => ({
93
+ send: vi.fn().mockResolvedValue([
94
+ {
95
+ clusterKey: "test-cluster",
96
+ nodeVersion: __VERSION__,
97
+ nodeTime,
98
+ },
99
+ null,
100
+ ]) as UnaryClient["send"],
101
+ use: vi.fn(),
102
+ });
103
+
104
+ it("should detect clock skew exceeding threshold", async () => {
105
+ const farFuture = TimeStamp.now().add(TimeSpan.hours(1));
106
+ const checker = new connection.Checker(
107
+ createMockClient(farFuture),
108
+ TimeSpan.seconds(30),
109
+ __VERSION__,
110
+ undefined,
111
+ TimeSpan.seconds(1),
112
+ );
113
+ const state = await checker.check();
114
+ expect(state.clockSkewExceeded).toBe(true);
115
+ expect(state.clockSkew.valueOf()).not.toBe(0n);
116
+ checker.stop();
117
+ });
118
+
119
+ it("should not flag skew within threshold", async () => {
120
+ const now = TimeStamp.now();
121
+ const checker = new connection.Checker(
122
+ createMockClient(now),
123
+ TimeSpan.seconds(30),
124
+ __VERSION__,
125
+ undefined,
126
+ TimeSpan.seconds(1),
127
+ );
128
+ const state = await checker.check();
129
+ expect(state.clockSkewExceeded).toBe(false);
130
+ checker.stop();
131
+ });
132
+
133
+ it("should fire onChange when clockSkewExceeded changes", async () => {
134
+ let callCount = 0;
135
+ const farFuture = TimeStamp.now().add(TimeSpan.hours(1));
136
+ const checker = new connection.Checker(
137
+ createMockClient(farFuture),
138
+ TimeSpan.seconds(30),
139
+ __VERSION__,
140
+ undefined,
141
+ TimeSpan.seconds(1),
142
+ );
143
+ // Wait for the constructor's initial check to complete
144
+ await checker.check();
145
+ checker.onChange(() => {
146
+ callCount++;
147
+ });
148
+ // Trigger another check - skewExceeded stays true, status stays connected,
149
+ // so onChange should not fire
150
+ await checker.check();
151
+ expect(callCount).toBe(0);
152
+ checker.stop();
153
+ });
154
+ });
90
155
  });
@@ -46,13 +46,14 @@ export class StateTracker
46
46
  implements observe.ObservableAsyncCloseable<Transfer[]>
47
47
  {
48
48
  readonly states: Map<channel.Key, State>;
49
- private readonly codec: binary.Codec;
49
+ private readonly codec: binary.JSONCodec;
50
50
 
51
51
  constructor(streamer: framer.Streamer) {
52
52
  super(streamer, (frame) => {
53
- const update: Update = this.codec.decode(frame.series[0].buffer, updateZ);
54
- this.merge(update);
55
- return [update.transfers, true];
53
+ const raw = Array.from(frame.series[0].as("string"));
54
+ const updates: Update[] = raw.map((r) => this.codec.decodeString(r, updateZ));
55
+ updates.forEach((u) => this.merge(u));
56
+ return [updates.flatMap((u) => u.transfers), true];
56
57
  });
57
58
  this.states = new Map();
58
59
  this.codec = new binary.JSONCodec();
@@ -312,11 +312,13 @@ describe("Device", async () => {
312
312
  });
313
313
 
314
314
  it("should retrieve devices by search term", async () => {
315
- const result = await client.devices.retrieve({
316
- searchTerm: "sensor1",
317
- });
318
- expect(result.length).toBeGreaterThanOrEqual(2);
319
- expect(result.every((d) => d.name.includes("sensor"))).toBe(true);
315
+ const sensor1 = testDevices.find((d) => d.name === "sensor1")!;
316
+ await expect
317
+ .poll(async () => {
318
+ const result = await client.devices.retrieve({ searchTerm: "sensor1" });
319
+ return result.find((d) => d.key === sensor1.key);
320
+ })
321
+ .toMatchObject({ name: "sensor1", key: sensor1.key });
320
322
  });
321
323
 
322
324
  it("should support pagination with limit and offset", async () => {
@@ -180,6 +180,18 @@ export class Client {
180
180
 
181
181
  async readLatest(channels: channel.Params, n: number): Promise<Frame>;
182
182
 
183
+ /**
184
+ * Reads the latest n samples from the given channel(s).
185
+ *
186
+ * If fewer than n samples are available, returns only the samples that
187
+ * exist.
188
+ *
189
+ * @param channels - A single channel key/name or an array of channel
190
+ * keys/names.
191
+ * @param n - The maximum number of samples to read. Defaults to 1.
192
+ * @returns A MultiSeries when a single channel is provided, or a Frame when
193
+ * multiple channels are provided.
194
+ */
183
195
  async readLatest(
184
196
  channels: channel.Params,
185
197
  n: number = 1,