@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.
- package/.turbo/turbo-build.log +8 -8
- package/dist/src/binary/codec.d.ts.map +1 -1
- package/dist/src/color/color.d.ts +1 -1
- package/dist/src/color/palette.d.ts +2 -2
- package/dist/src/deep/atKeys.d.ts +27 -0
- package/dist/src/deep/atKeys.d.ts.map +1 -0
- package/dist/src/deep/atKeys.spec.d.ts +2 -0
- package/dist/src/deep/atKeys.spec.d.ts.map +1 -0
- package/dist/src/deep/external.d.ts +1 -0
- package/dist/src/deep/external.d.ts.map +1 -1
- package/dist/src/fmt/external.d.ts +3 -0
- package/dist/src/fmt/external.d.ts.map +1 -0
- package/dist/src/fmt/index.d.ts +2 -0
- package/dist/src/fmt/index.d.ts.map +1 -0
- package/dist/src/fmt/path.d.ts +13 -0
- package/dist/src/fmt/path.d.ts.map +1 -0
- package/dist/src/fmt/path.spec.d.ts +2 -0
- package/dist/src/fmt/path.spec.d.ts.map +1 -0
- package/dist/src/fmt/value.d.ts +23 -0
- package/dist/src/fmt/value.d.ts.map +1 -0
- package/dist/src/fmt/value.spec.d.ts +2 -0
- package/dist/src/fmt/value.spec.d.ts.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/label/types.gen.d.ts +2 -2
- package/dist/src/narrow/narrow.d.ts +9 -0
- package/dist/src/narrow/narrow.d.ts.map +1 -1
- package/dist/src/primitive/primitive.d.ts +10 -0
- package/dist/src/primitive/primitive.d.ts.map +1 -1
- package/dist/src/status/status.d.ts +14 -2
- package/dist/src/status/status.d.ts.map +1 -1
- package/dist/src/status/types.gen.d.ts +1 -1
- package/dist/src/strings/strings.d.ts +9 -0
- package/dist/src/strings/strings.d.ts.map +1 -1
- package/dist/src/telem/clockSkew.d.ts +17 -0
- package/dist/src/telem/clockSkew.d.ts.map +1 -0
- package/dist/src/telem/clockSkew.spec.d.ts +2 -0
- package/dist/src/telem/clockSkew.spec.d.ts.map +1 -0
- package/dist/src/telem/external.d.ts +1 -0
- package/dist/src/telem/external.d.ts.map +1 -1
- package/dist/src/telem/series.d.ts +5 -5
- package/dist/src/telem/series.d.ts.map +1 -1
- package/dist/src/telem/telem.d.ts +16 -15
- package/dist/src/telem/telem.d.ts.map +1 -1
- package/dist/src/zod/external.d.ts +1 -0
- package/dist/src/zod/external.d.ts.map +1 -1
- package/dist/src/zod/parse.d.ts +47 -0
- package/dist/src/zod/parse.d.ts.map +1 -0
- package/dist/src/zod/parse.spec.d.ts +2 -0
- package/dist/src/zod/parse.spec.d.ts.map +1 -0
- package/dist/x.cjs +13 -7
- package/dist/x.js +3277 -2892
- package/package.json +8 -8
- package/src/binary/codec.ts +3 -2
- package/src/deep/atKeys.spec.ts +107 -0
- package/src/deep/atKeys.ts +49 -0
- package/src/deep/external.ts +1 -0
- package/src/fmt/external.ts +11 -0
- package/src/fmt/index.ts +10 -0
- package/src/fmt/path.spec.ts +46 -0
- package/src/fmt/path.ts +30 -0
- package/src/fmt/value.spec.ts +206 -0
- package/src/fmt/value.ts +83 -0
- package/src/index.ts +1 -0
- package/src/narrow/narrow.spec.ts +43 -0
- package/src/narrow/narrow.ts +15 -0
- package/src/primitive/primitive.spec.ts +51 -0
- package/src/primitive/primitive.ts +12 -0
- package/src/status/status.spec.ts +146 -0
- package/src/status/status.ts +65 -18
- package/src/strings/strings.spec.ts +19 -0
- package/src/strings/strings.ts +16 -0
- package/src/telem/clockSkew.spec.ts +58 -0
- package/src/telem/clockSkew.ts +46 -0
- package/src/telem/external.ts +1 -0
- package/src/telem/series.spec.ts +52 -4
- package/src/telem/series.ts +118 -42
- package/src/telem/telem.spec.ts +19 -0
- package/src/telem/telem.ts +10 -5
- package/src/zod/external.ts +1 -0
- package/src/zod/parse.spec.ts +702 -0
- package/src/zod/parse.ts +519 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/narrow/narrow.ts
CHANGED
|
@@ -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
|
});
|
package/src/status/status.ts
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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&b"));
|
|
110
|
+
|
|
111
|
+
it("should escape angle brackets", () =>
|
|
112
|
+
expect(strings.escapeHTML("<div>")).toBe("<div>"));
|
|
113
|
+
|
|
114
|
+
it("should escape quotes", () =>
|
|
115
|
+
expect(strings.escapeHTML(`"it's"`)).toBe(""it's""));
|
|
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
|
+
"<a href="x">&",
|
|
123
|
+
));
|
|
124
|
+
});
|
package/src/strings/strings.ts
CHANGED
|
@@ -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, "&")
|
|
123
|
+
.replace(/</g, "<")
|
|
124
|
+
.replace(/>/g, ">")
|
|
125
|
+
.replace(/"/g, """)
|
|
126
|
+
.replace(/'/g, "'");
|
|
@@ -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
|
+
}
|
package/src/telem/external.ts
CHANGED
|
@@ -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";
|
package/src/telem/series.spec.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
218
|
-
expect(
|
|
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:
|
|
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", () => {
|