@synnaxlabs/x 0.54.1 → 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 (83) hide show
  1. package/.turbo/turbo-build.log +8 -8
  2. package/dist/src/binary/codec.d.ts.map +1 -1
  3. package/dist/src/color/color.d.ts +1 -1
  4. package/dist/src/color/palette.d.ts +2 -2
  5. package/dist/src/deep/atKeys.d.ts +27 -0
  6. package/dist/src/deep/atKeys.d.ts.map +1 -0
  7. package/dist/src/deep/atKeys.spec.d.ts +2 -0
  8. package/dist/src/deep/atKeys.spec.d.ts.map +1 -0
  9. package/dist/src/deep/external.d.ts +1 -0
  10. package/dist/src/deep/external.d.ts.map +1 -1
  11. package/dist/src/fmt/external.d.ts +3 -0
  12. package/dist/src/fmt/external.d.ts.map +1 -0
  13. package/dist/src/fmt/index.d.ts +2 -0
  14. package/dist/src/fmt/index.d.ts.map +1 -0
  15. package/dist/src/fmt/path.d.ts +13 -0
  16. package/dist/src/fmt/path.d.ts.map +1 -0
  17. package/dist/src/fmt/path.spec.d.ts +2 -0
  18. package/dist/src/fmt/path.spec.d.ts.map +1 -0
  19. package/dist/src/fmt/value.d.ts +23 -0
  20. package/dist/src/fmt/value.d.ts.map +1 -0
  21. package/dist/src/fmt/value.spec.d.ts +2 -0
  22. package/dist/src/fmt/value.spec.d.ts.map +1 -0
  23. package/dist/src/index.d.ts +1 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/label/types.gen.d.ts +2 -2
  26. package/dist/src/narrow/narrow.d.ts +9 -0
  27. package/dist/src/narrow/narrow.d.ts.map +1 -1
  28. package/dist/src/primitive/primitive.d.ts +10 -0
  29. package/dist/src/primitive/primitive.d.ts.map +1 -1
  30. package/dist/src/status/status.d.ts +14 -2
  31. package/dist/src/status/status.d.ts.map +1 -1
  32. package/dist/src/status/types.gen.d.ts +1 -1
  33. package/dist/src/strings/strings.d.ts +9 -0
  34. package/dist/src/strings/strings.d.ts.map +1 -1
  35. package/dist/src/telem/clockSkew.d.ts +17 -0
  36. package/dist/src/telem/clockSkew.d.ts.map +1 -0
  37. package/dist/src/telem/clockSkew.spec.d.ts +2 -0
  38. package/dist/src/telem/clockSkew.spec.d.ts.map +1 -0
  39. package/dist/src/telem/external.d.ts +1 -0
  40. package/dist/src/telem/external.d.ts.map +1 -1
  41. package/dist/src/telem/series.d.ts +5 -5
  42. package/dist/src/telem/series.d.ts.map +1 -1
  43. package/dist/src/telem/telem.d.ts +16 -15
  44. package/dist/src/telem/telem.d.ts.map +1 -1
  45. package/dist/src/zod/external.d.ts +1 -0
  46. package/dist/src/zod/external.d.ts.map +1 -1
  47. package/dist/src/zod/parse.d.ts +47 -0
  48. package/dist/src/zod/parse.d.ts.map +1 -0
  49. package/dist/src/zod/parse.spec.d.ts +2 -0
  50. package/dist/src/zod/parse.spec.d.ts.map +1 -0
  51. package/dist/x.cjs +13 -7
  52. package/dist/x.js +3277 -2892
  53. package/package.json +8 -8
  54. package/src/binary/codec.ts +3 -2
  55. package/src/deep/atKeys.spec.ts +107 -0
  56. package/src/deep/atKeys.ts +49 -0
  57. package/src/deep/external.ts +1 -0
  58. package/src/fmt/external.ts +11 -0
  59. package/src/fmt/index.ts +10 -0
  60. package/src/fmt/path.spec.ts +46 -0
  61. package/src/fmt/path.ts +30 -0
  62. package/src/fmt/value.spec.ts +206 -0
  63. package/src/fmt/value.ts +83 -0
  64. package/src/index.ts +1 -0
  65. package/src/narrow/narrow.spec.ts +43 -0
  66. package/src/narrow/narrow.ts +15 -0
  67. package/src/primitive/primitive.spec.ts +51 -0
  68. package/src/primitive/primitive.ts +12 -0
  69. package/src/status/status.spec.ts +146 -0
  70. package/src/status/status.ts +65 -18
  71. package/src/strings/strings.spec.ts +19 -0
  72. package/src/strings/strings.ts +16 -0
  73. package/src/telem/clockSkew.spec.ts +58 -0
  74. package/src/telem/clockSkew.ts +46 -0
  75. package/src/telem/external.ts +1 -0
  76. package/src/telem/series.spec.ts +52 -4
  77. package/src/telem/series.ts +118 -42
  78. package/src/telem/telem.spec.ts +19 -0
  79. package/src/telem/telem.ts +10 -5
  80. package/src/zod/external.ts +1 -0
  81. package/src/zod/parse.spec.ts +702 -0
  82. package/src/zod/parse.ts +519 -0
  83. package/tsconfig.tsbuildinfo +1 -1
@@ -18,3 +18,18 @@ export type IsUndefined<T> = [T] extends [undefined] // T can be assigned to und
18
18
  export const isObject = <T extends record.Unknown = record.Unknown>(
19
19
  item?: unknown,
20
20
  ): item is T => item != null && typeof item === "object" && !Array.isArray(item);
21
+
22
+ /**
23
+ * A stricter version of {@link isObject} that additionally rejects class instances,
24
+ * arrays, and any non-`Object.prototype` objects. Returns true only for plain objects
25
+ * created via `{}`, `Object.create(null)`, or an object literal.
26
+ *
27
+ * Useful for walkers that need to distinguish "plain data bag" from "wrapped instance"
28
+ * (e.g. `Date`, `Map`, `Error`), which `isObject` treats the same.
29
+ */
30
+ export const isPlainObject = (item?: unknown): item is Record<string, unknown> => {
31
+ if (item == null || typeof item !== "object") return false;
32
+ if (Array.isArray(item)) return false;
33
+ const proto = Object.getPrototypeOf(item);
34
+ return proto === Object.prototype || proto === null;
35
+ };
@@ -64,6 +64,57 @@ describe("primitive", () => {
64
64
  });
65
65
  });
66
66
 
67
+ describe("is", () => {
68
+ it("should return true for null and undefined", () => {
69
+ expect(primitive.is(null)).toBe(true);
70
+ expect(primitive.is(undefined)).toBe(true);
71
+ });
72
+
73
+ it("should return true for strings", () => {
74
+ expect(primitive.is("")).toBe(true);
75
+ expect(primitive.is("hello")).toBe(true);
76
+ });
77
+
78
+ it("should return true for numbers", () => {
79
+ expect(primitive.is(0)).toBe(true);
80
+ expect(primitive.is(3.14)).toBe(true);
81
+ expect(primitive.is(NaN)).toBe(true);
82
+ });
83
+
84
+ it("should return true for booleans", () => {
85
+ expect(primitive.is(true)).toBe(true);
86
+ expect(primitive.is(false)).toBe(true);
87
+ });
88
+
89
+ it("should return true for bigints", () => {
90
+ expect(primitive.is(42n)).toBe(true);
91
+ });
92
+
93
+ it("should return true for symbols", () => {
94
+ expect(primitive.is(Symbol("x"))).toBe(true);
95
+ });
96
+
97
+ it("should return false for plain objects", () => {
98
+ expect(primitive.is({})).toBe(false);
99
+ expect(primitive.is({ a: 1 })).toBe(false);
100
+ });
101
+
102
+ it("should return false for arrays", () => {
103
+ expect(primitive.is([])).toBe(false);
104
+ expect(primitive.is([1, 2, 3])).toBe(false);
105
+ });
106
+
107
+ it("should return false for class instances", () => {
108
+ expect(primitive.is(new Date())).toBe(false);
109
+ expect(primitive.is(new Map())).toBe(false);
110
+ expect(primitive.is(new Error())).toBe(false);
111
+ });
112
+
113
+ it("should return false for functions", () => {
114
+ expect(primitive.is(() => 1)).toBe(false);
115
+ });
116
+ });
117
+
67
118
  describe("ValueExtension", () => {
68
119
  class MyValueExtension extends primitive.ValueExtension<bigint> {
69
120
  valueOf(): bigint {
@@ -105,3 +105,15 @@ export const isZero = <V extends Value>(value: V): value is V & ZeroValue => {
105
105
  */
106
106
  export const isNonZero = <V extends Value>(value: V): value is V & NonZeroValue =>
107
107
  !isZero(value);
108
+
109
+ /**
110
+ * @returns true if the given value is a JavaScript primitive: `null`, `undefined`,
111
+ * or a value whose `typeof` is not `"object"` or `"function"`. Returns false for
112
+ * objects (including arrays), functions, and class instances.
113
+ *
114
+ * Complements {@link isZero} and {@link isNonZero}, which check for the zero value
115
+ * of a known primitive type. Use `is` when you have an `unknown` value and want to
116
+ * decide whether to render it inline vs. recurse into it.
117
+ */
118
+ export const is = (value: unknown): boolean =>
119
+ value == null || (typeof value !== "object" && typeof value !== "function");
@@ -14,6 +14,8 @@ import { id } from "@/id";
14
14
  import { status } from "@/status";
15
15
  import { TimeStamp } from "@/telem";
16
16
 
17
+ type CustomCrude = Partial<status.Crude<z.ZodRecord, "error">>;
18
+
17
19
  describe("status", () => {
18
20
  describe("create", () => {
19
21
  it("should create a status", () => {
@@ -129,6 +131,117 @@ describe("status", () => {
129
131
  const result = status.exceptionDetailsSchema.safeParse(s.details);
130
132
  expect(result.success).toBe(true);
131
133
  });
134
+
135
+ describe("custom errors (toStatus)", () => {
136
+ class CustomError extends Error implements status.Custom {
137
+ constructor(message: string) {
138
+ super(message);
139
+ this.name = "CustomError";
140
+ }
141
+
142
+ toStatus(): CustomCrude {
143
+ return {
144
+ message: "custom headline",
145
+ description: "custom description body",
146
+ details: { code: 42, hint: "try again" },
147
+ };
148
+ }
149
+ }
150
+
151
+ it("should use toStatus.message as the status message", () => {
152
+ const s = status.fromException(new CustomError("raw"));
153
+ expect(s.message).toBe("custom headline");
154
+ });
155
+
156
+ it("should use toStatus.description as the status description", () => {
157
+ const s = status.fromException(new CustomError("raw"));
158
+ expect(s.description).toBe("custom description body");
159
+ });
160
+
161
+ it("should merge toStatus.details into status.details", () => {
162
+ const s = status.fromException(new CustomError("raw"));
163
+ const details = s.details as Record<string, unknown>;
164
+ expect(details.code).toBe(42);
165
+ expect(details.hint).toBe("try again");
166
+ expect(details.stack).toBeDefined();
167
+ expect(details.error).toBeInstanceOf(Error);
168
+ });
169
+
170
+ it("should prefix toStatus.message when the caller provides a custom message", () => {
171
+ const s = status.fromException(new CustomError("raw"), "Saving failed");
172
+ expect(s.message).toBe("Saving failed: custom headline");
173
+ });
174
+
175
+ it("should fall through to the default path for errors without toStatus", () => {
176
+ const s = status.fromException(new Error("plain"));
177
+ expect(s.message).toBe("plain");
178
+ expect(s.description).toBeUndefined();
179
+ });
180
+
181
+ it("should ignore toStatus if it throws", () => {
182
+ class Bad extends Error {
183
+ toStatus() {
184
+ throw new Error("explode");
185
+ }
186
+ }
187
+ const s = status.fromException(new Bad("fallback"));
188
+ expect(s.message).toBe("fallback");
189
+ expect(s.description).toBeUndefined();
190
+ });
191
+
192
+ it("should ignore toStatus if it returns a non-object", () => {
193
+ class Bad extends Error {
194
+ toStatus() {
195
+ return "nope" as unknown as CustomCrude;
196
+ }
197
+ }
198
+ const s = status.fromException(new Bad("fallback"));
199
+ expect(s.message).toBe("fallback");
200
+ });
201
+
202
+ it("should ignore toStatus if it returns null", () => {
203
+ class Bad extends Error {
204
+ toStatus() {
205
+ return null as unknown as CustomCrude;
206
+ }
207
+ }
208
+ const s = status.fromException(new Bad("fallback"));
209
+ expect(s.message).toBe("fallback");
210
+ });
211
+
212
+ it("should ignore individual fields with the wrong type", () => {
213
+ class Bad extends Error {
214
+ toStatus() {
215
+ return {
216
+ message: 42 as unknown as string,
217
+ description: { not: "a string" } as unknown as string,
218
+ details: "also not a record" as unknown as Record<string, unknown>,
219
+ };
220
+ }
221
+ }
222
+ const s = status.fromException(new Bad("fallback"));
223
+ expect(s.message).toBe("fallback");
224
+ expect(s.description).toBeUndefined();
225
+ });
226
+
227
+ it("should accept a partial return with only some valid fields", () => {
228
+ class Partial extends Error {
229
+ toStatus() {
230
+ return { message: "only headline" };
231
+ }
232
+ }
233
+ const s = status.fromException(new Partial("raw"));
234
+ expect(s.message).toBe("only headline");
235
+ expect(s.description).toBeUndefined();
236
+ });
237
+
238
+ it("should ignore toStatus if it is not a function", () => {
239
+ const err = new Error("fallback") as Error & { toStatus: string };
240
+ err.toStatus = "not a function";
241
+ const s = status.fromException(err);
242
+ expect(s.message).toBe("fallback");
243
+ });
244
+ });
132
245
  });
133
246
 
134
247
  describe("toString", () => {
@@ -301,5 +414,38 @@ describe("status", () => {
301
414
  const result = status.toString(s);
302
415
  expect(result).toContain("cat");
303
416
  });
417
+
418
+ describe("custom errors (toStatus)", () => {
419
+ class CustomError extends Error implements status.Custom {
420
+ constructor() {
421
+ super("raw");
422
+ this.name = "CustomError";
423
+ }
424
+
425
+ toStatus(): CustomCrude {
426
+ return {
427
+ message: "custom headline",
428
+ description: "line one\nline two\nline three",
429
+ details: { code: 42 },
430
+ };
431
+ }
432
+ }
433
+
434
+ it("should render the toStatus headline as the status message", () => {
435
+ const s = status.fromException(new CustomError());
436
+ expect(status.toString(s)).toContain("ERROR: custom headline");
437
+ });
438
+
439
+ it("should render a multi-line description on its own block", () => {
440
+ const out = status.toString(status.fromException(new CustomError()));
441
+ expect(out).toContain("Description:\nline one\nline two\nline three");
442
+ });
443
+
444
+ it("should include merged details under the Details section", () => {
445
+ const out = status.toString(status.fromException(new CustomError()));
446
+ expect(out).toContain("Details:");
447
+ expect(out).toContain('"code": 42');
448
+ });
449
+ });
304
450
  });
305
451
  });
@@ -13,6 +13,7 @@ import { id } from "@/id";
13
13
  import { narrow } from "@/narrow";
14
14
  import { type optional } from "@/optional";
15
15
  import { primitive } from "@/primitive";
16
+ import { record } from "@/record";
16
17
  import { type Status, type Variant } from "@/status/types.gen";
17
18
  import { TimeStamp } from "@/telem";
18
19
 
@@ -32,22 +33,68 @@ export type Crude<
32
33
  > = optional.Optional<Base<V>, "key" | "time" | "name"> &
33
34
  ([DetailsSchema] extends [z.ZodNever] ? {} : { details: z.output<DetailsSchema> });
34
35
 
35
- export const exceptionDetailsSchema = z.object({
36
- stack: z.string(),
37
- error: z.instanceof(Error),
36
+ /**
37
+ * Interface that errors may optionally implement to provide richer rendering when
38
+ * passed to {@link fromException}. Implementers return a partial {@link Crude} spec
39
+ * whose fields override the defaults derived from the underlying `Error`.
40
+ *
41
+ * This is a duck-typed contract: `fromException` checks for the presence of a
42
+ * `toStatus` method via the `in` operator, so there is no need to import this
43
+ * interface to use it.
44
+ */
45
+ export interface Custom {
46
+ toStatus(): Partial<Crude<z.ZodRecord, "error">>;
47
+ }
48
+
49
+ const customReturnZ = z.object({
50
+ message: z.string().optional(),
51
+ description: z.string().optional(),
52
+ details: record.unknownZ().optional(),
38
53
  });
39
54
 
55
+ const hasToStatusMethod = (exc: Error): exc is Error & { toStatus: () => unknown } =>
56
+ "toStatus" in exc && typeof (exc as { toStatus: unknown }).toStatus === "function";
57
+
58
+ const safeToStatus = (exc: Error): z.infer<typeof customReturnZ> | undefined => {
59
+ if (!hasToStatusMethod(exc)) return undefined;
60
+ let raw: unknown;
61
+ try {
62
+ raw = exc.toStatus();
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ const parsed = customReturnZ.safeParse(raw);
67
+ return parsed.success ? parsed.data : undefined;
68
+ };
69
+
70
+ export const exceptionDetailsSchema = z
71
+ .object({
72
+ stack: z.string(),
73
+ error: z.instanceof(Error),
74
+ })
75
+ .and(record.unknownZ());
76
+
40
77
  export const fromException = (
41
78
  exc: unknown,
42
79
  message?: string,
43
80
  ): Status<typeof exceptionDetailsSchema, z.ZodLiteral<"error">> => {
44
81
  if (!(exc instanceof Error)) throw exc;
45
- return create<typeof exceptionDetailsSchema, "error">({
82
+ const crude: Crude<typeof exceptionDetailsSchema, "error"> = {
46
83
  variant: "error",
47
84
  message: message ?? exc.message,
48
85
  description: message != null ? exc.message : undefined,
49
86
  details: { stack: exc.stack ?? "", error: exc },
50
- });
87
+ };
88
+ const custom = safeToStatus(exc);
89
+ if (custom != null) {
90
+ if (message != null && custom.message != null)
91
+ crude.message = `${message}: ${custom.message}`;
92
+ else if (custom.message != null) crude.message = custom.message;
93
+ if (custom.description != null) crude.description = custom.description;
94
+ if (custom.details != null && crude.details != null)
95
+ crude.details = { ...crude.details, ...custom.details };
96
+ }
97
+ return create<typeof exceptionDetailsSchema, "error">(crude);
51
98
  };
52
99
 
53
100
  export const create = <
@@ -97,6 +144,16 @@ const DEFAULT_TO_STRING_OPTIONS: ToStringOptions = {
97
144
  includeName: true,
98
145
  };
99
146
 
147
+ const renderDescription = (description: string): string => {
148
+ if (description.includes("\n")) return `Description:\n${description}`;
149
+ try {
150
+ const parsed = JSON.parse(description);
151
+ return `Description:\n${JSON.stringify(parsed, null, 2)}`;
152
+ } catch {
153
+ return `Description: ${description}`;
154
+ }
155
+ };
156
+
100
157
  export const toString = <Details extends z.ZodType = z.ZodNever>(
101
158
  stat: Status<Details>,
102
159
  options: ToStringOptions = {},
@@ -108,21 +165,11 @@ export const toString = <Details extends z.ZodType = z.ZodNever>(
108
165
  header += `: ${stat.message}`;
109
166
  if (opts.includeTimestamp) header += ` (${stat.time.toString("dateTime", "local")})`;
110
167
  parts.push(header);
111
- if (stat.description != null) {
112
- let descriptionText: string;
113
- try {
114
- const parsed = JSON.parse(stat.description);
115
- descriptionText = `Description:\n${JSON.stringify(parsed, null, 2)}`;
116
- } catch {
117
- descriptionText = `Description: ${stat.description}`;
118
- }
119
- parts.push(descriptionText);
120
- }
168
+ if (stat.description != null) parts.push(renderDescription(stat.description));
121
169
  if ("details" in stat && narrow.isObject(stat.details)) {
122
170
  const details = stat.details as Record<string, unknown>;
123
- // Extract stack trace separately for special formatting
124
- if ("stack" in details) parts.push(`Stack Trace:\n${String(details.stack)}`);
125
- // Include other details (excluding stack and error which don't serialize well)
171
+ if ("stack" in details && typeof details.stack === "string" && details.stack !== "")
172
+ parts.push(`Stack Trace:\n${details.stack}`);
126
173
  const extraDetails = Object.fromEntries(
127
174
  Object.entries(details).filter(([k]) => k !== "stack" && k !== "error"),
128
175
  );
@@ -103,3 +103,22 @@ describe("trimPrefix", () => {
103
103
  it("should handle numbers in prefix", () =>
104
104
  expect(strings.trimPrefix("123abc", "123")).toBe("abc"));
105
105
  });
106
+
107
+ describe("escapeHTML", () => {
108
+ it("should escape ampersands", () =>
109
+ expect(strings.escapeHTML("a&b")).toBe("a&amp;b"));
110
+
111
+ it("should escape angle brackets", () =>
112
+ expect(strings.escapeHTML("<div>")).toBe("&lt;div&gt;"));
113
+
114
+ it("should escape quotes", () =>
115
+ expect(strings.escapeHTML(`"it's"`)).toBe("&quot;it&#39;s&quot;"));
116
+
117
+ it("should return the original string when no special characters", () =>
118
+ expect(strings.escapeHTML("hello")).toBe("hello"));
119
+
120
+ it("should escape all special characters together", () =>
121
+ expect(strings.escapeHTML(`<a href="x">&`)).toBe(
122
+ "&lt;a href=&quot;x&quot;&gt;&amp;",
123
+ ));
124
+ });
@@ -108,3 +108,19 @@ export const trimPrefix = (str: string, prefix: string): string => {
108
108
  if (str.startsWith(prefix)) return str.slice(prefix.length);
109
109
  return str;
110
110
  };
111
+
112
+ /**
113
+ * Escapes HTML special characters in a string to prevent XSS and ensure
114
+ * correct rendering in HTML contexts.
115
+ *
116
+ * @param s - The string to escape.
117
+ * @returns The escaped string with &, <, >, ", and ' replaced by their
118
+ * HTML entity equivalents.
119
+ */
120
+ export const escapeHTML = (s: string): string =>
121
+ s
122
+ .replace(/&/g, "&amp;")
123
+ .replace(/</g, "&lt;")
124
+ .replace(/>/g, "&gt;")
125
+ .replace(/"/g, "&quot;")
126
+ .replace(/'/g, "&#39;");
@@ -0,0 +1,58 @@
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 { describe, expect, it } from "vitest";
11
+
12
+ import { ClockSkewCalculator, TimeSpan, TimeStamp } from "@/telem";
13
+
14
+ describe("ClockSkewCalculator", () => {
15
+ it("should correctly calculate clock skew from a single measurement", () => {
16
+ let mockTime = TimeStamp.seconds(0);
17
+ const calc = new ClockSkewCalculator(() => mockTime);
18
+ calc.start();
19
+ mockTime = TimeStamp.seconds(10);
20
+ // Remote midpoint is 3s, local midpoint is 5s, so skew is 2s
21
+ calc.end(TimeStamp.seconds(3));
22
+ expect(calc.skew).toEqual(TimeSpan.seconds(2));
23
+ expect(calc.exceeds(TimeSpan.seconds(1))).toBe(true);
24
+ expect(calc.exceeds(TimeSpan.seconds(3))).toBe(false);
25
+ });
26
+
27
+ it("should report zero skew when times match perfectly", () => {
28
+ let mockTime = TimeStamp.seconds(0);
29
+ const calc = new ClockSkewCalculator(() => mockTime);
30
+ calc.start();
31
+ mockTime = TimeStamp.seconds(10);
32
+ // Remote midpoint matches local midpoint at 5s
33
+ calc.end(TimeStamp.seconds(5));
34
+ expect(calc.skew).toEqual(TimeSpan.ZERO);
35
+ expect(calc.exceeds(TimeSpan.seconds(1))).toBe(false);
36
+ });
37
+
38
+ it("should return the most recent measurement", () => {
39
+ let mockTime = TimeStamp.seconds(0);
40
+ const calc = new ClockSkewCalculator(() => mockTime);
41
+ calc.start();
42
+ mockTime = TimeStamp.seconds(10);
43
+ calc.end(TimeStamp.seconds(3));
44
+ expect(calc.skew).toEqual(TimeSpan.seconds(2));
45
+ mockTime = TimeStamp.seconds(0);
46
+ calc.start();
47
+ mockTime = TimeStamp.seconds(10);
48
+ // Remote midpoint is 7s, local midpoint is 5s, so skew is -2s
49
+ calc.end(TimeStamp.seconds(7));
50
+ expect(calc.skew).toEqual(TimeSpan.seconds(-2));
51
+ });
52
+
53
+ it("should return zero skew when no measurements taken", () => {
54
+ const calc = new ClockSkewCalculator();
55
+ expect(calc.skew).toEqual(TimeSpan.ZERO);
56
+ expect(calc.exceeds(TimeSpan.seconds(1))).toBe(false);
57
+ });
58
+ });
@@ -0,0 +1,46 @@
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 { type CrudeTimeSpan, TimeSpan, TimeStamp } from "@/telem/telem";
11
+
12
+ /**
13
+ * Calculates and tracks clock skew between two systems using a midpoint
14
+ * synchronization algorithm. Useful for distributed systems where clock
15
+ * synchronization is critical.
16
+ */
17
+ export class ClockSkewCalculator {
18
+ private readonly now: () => TimeStamp;
19
+ private localStartT: TimeStamp = new TimeStamp(0);
20
+ private lastSkew: TimeSpan = TimeSpan.ZERO;
21
+
22
+ constructor(now: () => TimeStamp = () => TimeStamp.now()) {
23
+ this.now = now;
24
+ }
25
+
26
+ start(): void {
27
+ this.localStartT = this.now();
28
+ }
29
+
30
+ end(remoteMidpointT: TimeStamp): void {
31
+ const localEndT = this.now();
32
+ const halfSpan = localEndT.span(this.localStartT).valueOf() / 2n;
33
+ const thisMidpointT = this.localStartT.add(halfSpan);
34
+ this.lastSkew = new TimeSpan(thisMidpointT.valueOf() - remoteMidpointT.valueOf());
35
+ }
36
+
37
+ get skew(): TimeSpan {
38
+ return this.lastSkew;
39
+ }
40
+
41
+ exceeds(threshold: CrudeTimeSpan): boolean {
42
+ const skewVal = this.skew.valueOf();
43
+ const abs = skewVal < 0n ? -skewVal : skewVal;
44
+ return abs > new TimeSpan(threshold).valueOf();
45
+ }
46
+ }
@@ -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
+ export * from "@/telem/clockSkew";
10
11
  export { type GLBufferController } from "@/telem/gl";
11
12
  export * from "@/telem/series";
12
13
  export * from "@/telem/telem";
@@ -141,7 +141,7 @@ describe("Series", () => {
141
141
  const s = new Series({ data: [{ a: 1, b: "apple" }] });
142
142
  expect(s.dataType.equals(DataType.JSON));
143
143
  expect(s.length).toEqual(1);
144
- expect(s.data.at(-1)).toEqual(10);
144
+ expect(s.at(0)).toEqual({ a: 1, b: "apple" });
145
145
  });
146
146
 
147
147
  it("should correctly interpret a bigint as an int64", () => {
@@ -214,8 +214,8 @@ describe("Series", () => {
214
214
 
215
215
  it("should convert encoded keys to snake_case", () => {
216
216
  const a = new Series({ data: [{ aB: 1, bC: "apple" }], dataType: DataType.JSON });
217
- const strContent = new TextDecoder().decode(a.data);
218
- expect(strContent).toBe('{"a_b":1,"b_c":"apple"}\n');
217
+ expect(a.length).toEqual(1);
218
+ expect(a.at(0)).toEqual({ aB: 1, bC: "apple" });
219
219
  });
220
220
 
221
221
  it("should throw an error when an empty JS array is provided and no data type is provided", () => {
@@ -502,7 +502,7 @@ describe("Series", () => {
502
502
  });
503
503
 
504
504
  it("should recompute the length of a variable density array", () => {
505
- const series = Series.alloc({ capacity: 12, dataType: DataType.STRING });
505
+ const series = Series.alloc({ capacity: 18, dataType: DataType.STRING });
506
506
  expect(series.length).toEqual(0);
507
507
  const writeOne = new Series({ data: ["apple"] });
508
508
  expect(series.write(writeOne)).toEqual(1);
@@ -689,6 +689,54 @@ describe("Series", () => {
689
689
  { a: 3, b: "carrot" },
690
690
  ]);
691
691
  });
692
+
693
+ it("should handle a single JSON value", () => {
694
+ const s = new Series([{ key: "val" }]);
695
+ expect(s.length).toEqual(1);
696
+ expect(s.at(0)).toEqual({ key: "val" });
697
+ });
698
+
699
+ it("should handle empty JSON objects", () => {
700
+ const s = new Series([{}, {}, {}]);
701
+ expect(s.length).toEqual(3);
702
+ expect(s.at(0)).toEqual({});
703
+ expect(s.at(2)).toEqual({});
704
+ });
705
+
706
+ it("should support negative indexing", () => {
707
+ const s = new Series([{ a: 1 }, { a: 2 }, { a: 3 }]);
708
+ expect(s.at(-1)).toEqual({ a: 3 });
709
+ expect(s.at(-2)).toEqual({ a: 2 });
710
+ });
711
+
712
+ it("should handle an empty JSON series", () => {
713
+ const s = new Series({ data: new ArrayBuffer(0), dataType: DataType.JSON });
714
+ expect(s.length).toEqual(0);
715
+ expect(Array.from(s)).toEqual([]);
716
+ });
717
+ });
718
+
719
+ describe("bytes series", () => {
720
+ it("should handle an empty bytes series", () => {
721
+ const s = new Series({ data: new ArrayBuffer(0), dataType: DataType.BYTES });
722
+ expect(s.length).toEqual(0);
723
+ expect(Array.from(s)).toEqual([]);
724
+ });
725
+
726
+ it("should correctly compute the length of a bytes series from a length-prefixed buffer", () => {
727
+ const payload1 = new Uint8Array([1, 2, 3]);
728
+ const payload2 = new Uint8Array([4, 5]);
729
+ const totalBytes = 4 + payload1.byteLength + 4 + payload2.byteLength;
730
+ const buf = new ArrayBuffer(totalBytes);
731
+ const view = new DataView(buf);
732
+ const bytes = new Uint8Array(buf);
733
+ view.setUint32(0, payload1.byteLength, true);
734
+ bytes.set(payload1, 4);
735
+ view.setUint32(4 + payload1.byteLength, payload2.byteLength, true);
736
+ bytes.set(payload2, 4 + payload1.byteLength + 4);
737
+ const s = new Series({ data: buf, dataType: DataType.BYTES });
738
+ expect(s.length).toEqual(2);
739
+ });
692
740
  });
693
741
 
694
742
  describe("binarySearch", () => {