@synnaxlabs/x 0.54.1 → 0.54.2
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 +7 -7
- 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/telem/series.d.ts +4 -4
- package/dist/src/telem/telem.d.ts +10 -10
- 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 +16 -7
- package/dist/x.js +2190 -1869
- package/package.json +3 -3
- 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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synnaxlabs/x",
|
|
3
|
-
"version": "0.54.
|
|
3
|
+
"version": "0.54.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Common Utilities for Synnax Labs",
|
|
6
6
|
"repository": "https://github.com/synnaxlabs/synnax/tree/main/x/ts",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"typescript": "^6.0.2",
|
|
27
27
|
"vite": "^8.0.3",
|
|
28
28
|
"vitest": "^4.1.2",
|
|
29
|
-
"@synnaxlabs/eslint-config": "^0.0.0",
|
|
30
29
|
"@synnaxlabs/tsconfig": "^0.0.0",
|
|
31
|
-
"@synnaxlabs/vite-plugin": "^0.0.0"
|
|
30
|
+
"@synnaxlabs/vite-plugin": "^0.0.0",
|
|
31
|
+
"@synnaxlabs/eslint-config": "^0.0.0"
|
|
32
32
|
},
|
|
33
33
|
"main": "dist/x.cjs",
|
|
34
34
|
"module": "dist/x.js",
|
package/src/binary/codec.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type z } from "zod";
|
|
|
11
11
|
|
|
12
12
|
import { caseconv } from "@/caseconv";
|
|
13
13
|
import { narrow } from "@/narrow";
|
|
14
|
+
import { zod } from "@/zod";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Codec is an entity that encodes and decodes messages to and from a
|
|
@@ -62,11 +63,11 @@ export class JSONCodec implements Codec {
|
|
|
62
63
|
decodeString<P extends z.ZodType>(data: string, schema?: P): z.infer<P> {
|
|
63
64
|
const parsed = JSON.parse(data);
|
|
64
65
|
const unpacked = caseconv.snakeToCamel(parsed, { schema });
|
|
65
|
-
return schema != null ?
|
|
66
|
+
return schema != null ? zod.parse(schema, unpacked) : (unpacked as z.infer<P>);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
encodeString(payload: unknown, schema?: z.ZodType): string {
|
|
69
|
-
const parsed = schema != null ?
|
|
70
|
+
const parsed = schema != null ? zod.parse(schema, payload) : payload;
|
|
70
71
|
const caseConverted = caseconv.camelToSnake(parsed ?? {}, { schema });
|
|
71
72
|
return JSON.stringify(caseConverted, (_, v) => {
|
|
72
73
|
if (ArrayBuffer.isView(v)) return Array.from(v as Uint8Array);
|
|
@@ -0,0 +1,107 @@
|
|
|
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 { deep } from "@/deep";
|
|
13
|
+
|
|
14
|
+
describe("deep.atKeys", () => {
|
|
15
|
+
it("should return the value at a single-segment path", () => {
|
|
16
|
+
expect(deep.atKeys({ a: 1 }, ["a"])).toEqual({ present: true, value: 1 });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should return the value at a deeply nested path", () => {
|
|
20
|
+
expect(deep.atKeys({ a: { b: { c: "deep" } } }, ["a", "b", "c"])).toEqual({
|
|
21
|
+
present: true,
|
|
22
|
+
value: "deep",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should walk into arrays by numeric index", () => {
|
|
27
|
+
expect(deep.atKeys({ items: [10, 20, 30] }, ["items", 1])).toEqual({
|
|
28
|
+
present: true,
|
|
29
|
+
value: 20,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should return the root itself for an empty path", () => {
|
|
34
|
+
const root = { a: 1 };
|
|
35
|
+
expect(deep.atKeys(root, [])).toEqual({ present: true, value: root });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should mark a missing top-level key as not present", () => {
|
|
39
|
+
expect(deep.atKeys({ a: 1 }, ["b"])).toEqual({
|
|
40
|
+
present: false,
|
|
41
|
+
value: undefined,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should mark a missing nested key as not present", () => {
|
|
46
|
+
expect(deep.atKeys({ a: { b: 1 } }, ["a", "c"])).toEqual({
|
|
47
|
+
present: false,
|
|
48
|
+
value: undefined,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should return the value when a key is present with explicit undefined", () => {
|
|
53
|
+
expect(deep.atKeys({ a: undefined }, ["a"])).toEqual({
|
|
54
|
+
present: true,
|
|
55
|
+
value: undefined,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should return the value when a key is present with explicit null", () => {
|
|
60
|
+
expect(deep.atKeys({ a: null }, ["a"])).toEqual({
|
|
61
|
+
present: true,
|
|
62
|
+
value: null,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should distinguish present-but-null from missing for nested paths", () => {
|
|
67
|
+
expect(deep.atKeys({ a: { b: null } }, ["a", "b"])).toEqual({
|
|
68
|
+
present: true,
|
|
69
|
+
value: null,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should treat walking into a null value as not present", () => {
|
|
74
|
+
expect(deep.atKeys({ a: null }, ["a", "b"])).toEqual({
|
|
75
|
+
present: false,
|
|
76
|
+
value: undefined,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should treat walking into a primitive as not present", () => {
|
|
81
|
+
expect(deep.atKeys({ a: 5 }, ["a", "b"])).toEqual({
|
|
82
|
+
present: false,
|
|
83
|
+
value: undefined,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should not apply the deep.get 'find by .key' heuristic to arrays", () => {
|
|
88
|
+
const obj = { items: [{ key: "foo", value: 1 }] };
|
|
89
|
+
// deep.get would resolve ["items", "foo"] to the keyed item; atKeys does not.
|
|
90
|
+
expect(deep.atKeys(obj, ["items", "foo"])).toEqual({
|
|
91
|
+
present: false,
|
|
92
|
+
value: undefined,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should handle keys that contain literal dots unambiguously", () => {
|
|
97
|
+
const obj = { "foo.bar": 42 };
|
|
98
|
+
expect(deep.atKeys(obj, ["foo.bar"])).toEqual({ present: true, value: 42 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should return not-present when root itself is null", () => {
|
|
102
|
+
expect(deep.atKeys(null, ["a"])).toEqual({
|
|
103
|
+
present: false,
|
|
104
|
+
value: undefined,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
export interface AtKeysResult<V = unknown> {
|
|
11
|
+
/** True if every key in the path resolved to a property that is present on its
|
|
12
|
+
* container (via `in`), false if any segment was missing. A key with an explicit
|
|
13
|
+
* `undefined` or `null` value is still considered present. */
|
|
14
|
+
present: boolean;
|
|
15
|
+
/** The value at the path. Undefined when `present` is false. */
|
|
16
|
+
value: V | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Walks a value by a strict `PropertyKey[]` path and returns whether the path is
|
|
21
|
+
* present along with the value at that location.
|
|
22
|
+
*
|
|
23
|
+
* Differs from {@link get}/{@link has} in three important ways:
|
|
24
|
+
*
|
|
25
|
+
* 1. Takes a `PropertyKey[]` instead of a dotted string, so keys containing literal
|
|
26
|
+
* `.` are handled unambiguously.
|
|
27
|
+
* 2. Uses plain positional key lookup (`container[key]`) without the
|
|
28
|
+
* `defaultGetter` fallback that searches arrays for items with matching `.key`.
|
|
29
|
+
* 3. Distinguishes "key missing from container" from "key present with
|
|
30
|
+
* `null`/`undefined` value" by using the `in` operator, which `has` does not.
|
|
31
|
+
*
|
|
32
|
+
* Intended for consumers that receive paths as `PropertyKey[]` (e.g. zod issues,
|
|
33
|
+
* JSON-pointer-like walkers) and need to render the exact state of the input,
|
|
34
|
+
* including present-but-null fields, without the ergonomic heuristics of `get`.
|
|
35
|
+
*/
|
|
36
|
+
export const atKeys = <V = unknown>(
|
|
37
|
+
root: unknown,
|
|
38
|
+
path: ReadonlyArray<PropertyKey>,
|
|
39
|
+
): AtKeysResult<V> => {
|
|
40
|
+
let cur: unknown = root;
|
|
41
|
+
for (const key of path) {
|
|
42
|
+
if (cur == null || typeof cur !== "object")
|
|
43
|
+
return { present: false, value: undefined };
|
|
44
|
+
const container = cur as Record<PropertyKey, unknown>;
|
|
45
|
+
if (!(key in container)) return { present: false, value: undefined };
|
|
46
|
+
cur = container[key];
|
|
47
|
+
}
|
|
48
|
+
return { present: true, value: cur as V };
|
|
49
|
+
};
|
package/src/deep/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 "@/deep/atKeys";
|
|
10
11
|
export * from "@/deep/copy";
|
|
11
12
|
export * from "@/deep/difference";
|
|
12
13
|
export * from "@/deep/equal";
|
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
export * from "@/fmt/path";
|
|
11
|
+
export * from "@/fmt/value";
|
package/src/fmt/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
export * as fmt from "@/fmt/external";
|
|
@@ -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 { describe, expect, it } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { fmt } from "@/fmt";
|
|
13
|
+
|
|
14
|
+
describe("fmt.path", () => {
|
|
15
|
+
it("should render an empty path as <root>", () => {
|
|
16
|
+
expect(fmt.path([])).toBe("<root>");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should render a single string segment without a leading dot", () => {
|
|
20
|
+
expect(fmt.path(["name"])).toBe("name");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should join string segments with dots", () => {
|
|
24
|
+
expect(fmt.path(["a", "b", "c"])).toBe("a.b.c");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should render a single numeric segment as a bracket index", () => {
|
|
28
|
+
expect(fmt.path([0])).toBe("[0]");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should render numeric segments as bracket indices in a mixed path", () => {
|
|
32
|
+
expect(fmt.path(["items", 0, "name"])).toBe("items[0].name");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should render consecutive numeric segments as adjacent brackets", () => {
|
|
36
|
+
expect(fmt.path(["matrix", 1, 2])).toBe("matrix[1][2]");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should render a numeric root followed by a string as [0].name", () => {
|
|
40
|
+
expect(fmt.path([0, "name"])).toBe("[0].name");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should render a symbol segment via its String() form", () => {
|
|
44
|
+
expect(fmt.path([Symbol("foo")])).toBe("Symbol(foo)");
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/fmt/path.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Renders an object path as a human-readable string.
|
|
12
|
+
*
|
|
13
|
+
* - An empty path becomes `<root>`.
|
|
14
|
+
* - String segments are joined with `.`.
|
|
15
|
+
* - Numeric segments are rendered as bracket indices (`items[0]`).
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* path([]) // "<root>"
|
|
19
|
+
* path(["config", "channels", 0, "port"]) // "config.channels[0].port"
|
|
20
|
+
*/
|
|
21
|
+
export const path = (segments: ReadonlyArray<PropertyKey>): string => {
|
|
22
|
+
if (segments.length === 0) return "<root>";
|
|
23
|
+
let out = "";
|
|
24
|
+
segments.forEach((p, i) => {
|
|
25
|
+
if (typeof p === "number") out += `[${p}]`;
|
|
26
|
+
else if (i === 0) out += String(p);
|
|
27
|
+
else out += `.${String(p)}`;
|
|
28
|
+
});
|
|
29
|
+
return out;
|
|
30
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
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 { fmt } from "@/fmt";
|
|
13
|
+
|
|
14
|
+
describe("fmt.value", () => {
|
|
15
|
+
describe("primitives", () => {
|
|
16
|
+
it("should pass strings through unchanged", () => {
|
|
17
|
+
expect(fmt.value("hello")).toBe("hello");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should pass an empty string through unchanged", () => {
|
|
21
|
+
expect(fmt.value("")).toBe("");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should pass integers through unchanged", () => {
|
|
25
|
+
expect(fmt.value(42)).toBe(42);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should pass floats through unchanged", () => {
|
|
29
|
+
expect(fmt.value(3.14)).toBe(3.14);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should pass zero through unchanged", () => {
|
|
33
|
+
expect(fmt.value(0)).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should pass NaN through unchanged", () => {
|
|
37
|
+
expect(fmt.value(NaN)).toBeNaN();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should pass Infinity through unchanged", () => {
|
|
41
|
+
expect(fmt.value(Infinity)).toBe(Infinity);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should pass true through unchanged", () => {
|
|
45
|
+
expect(fmt.value(true)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should pass false through unchanged", () => {
|
|
49
|
+
expect(fmt.value(false)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should pass null through unchanged", () => {
|
|
53
|
+
expect(fmt.value(null)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should render undefined as [undefined]", () => {
|
|
57
|
+
expect(fmt.value(undefined)).toBe("[undefined]");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should render bigint with an n suffix", () => {
|
|
61
|
+
expect(fmt.value(BigInt(42))).toBe("42n");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should render 0n bigint as 0n", () => {
|
|
65
|
+
expect(fmt.value(BigInt(0))).toBe("0n");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should render functions as [function]", () => {
|
|
69
|
+
expect(fmt.value(() => 1)).toBe("[function]");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should render symbols as [symbol]", () => {
|
|
73
|
+
expect(fmt.value(Symbol("x"))).toBe("[symbol]");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("well-known objects", () => {
|
|
78
|
+
it("should render Date as its ISO string", () => {
|
|
79
|
+
expect(fmt.value(new Date("2026-01-01T00:00:00Z"))).toBe(
|
|
80
|
+
"2026-01-01T00:00:00.000Z",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should render Error as [Error: message]", () => {
|
|
85
|
+
expect(fmt.value(new Error("boom"))).toBe("[Error: boom]");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should render non-plain class instances as [object Object]", () => {
|
|
89
|
+
class Custom {}
|
|
90
|
+
expect(fmt.value(new Custom())).toBe("[[object Object]]");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("arrays", () => {
|
|
95
|
+
it("should render an empty array as an empty array", () => {
|
|
96
|
+
expect(fmt.value([])).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should recurse into array elements preserving order and type", () => {
|
|
100
|
+
expect(fmt.value([1, "two", true, null])).toEqual([1, "two", true, null]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should truncate arrays longer than maxArrayLength", () => {
|
|
104
|
+
expect(
|
|
105
|
+
fmt.value([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], {
|
|
106
|
+
maxArrayLength: 3,
|
|
107
|
+
}),
|
|
108
|
+
).toEqual([0, 1, 2, "…(+12 more)"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should not truncate arrays at or under maxArrayLength", () => {
|
|
112
|
+
expect(fmt.value([1, 2, 3], { maxArrayLength: 3 })).toEqual([1, 2, 3]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should mark arrays beyond maxDepth without enumerating", () => {
|
|
116
|
+
expect(fmt.value([[[[[1, 2]]]]], { maxDepth: 2 })).toEqual([["[Array(1)]"]]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("plain objects", () => {
|
|
121
|
+
it("should render an empty object as an empty object", () => {
|
|
122
|
+
expect(fmt.value({})).toEqual({});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should recurse into nested object values", () => {
|
|
126
|
+
expect(fmt.value({ a: 1, b: { c: "deep" } })).toEqual({
|
|
127
|
+
a: 1,
|
|
128
|
+
b: { c: "deep" },
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should mark objects beyond maxDepth as [Object]", () => {
|
|
133
|
+
expect(
|
|
134
|
+
fmt.value({ a: { b: { c: { d: { e: "x" } } } } }, { maxDepth: 2 }),
|
|
135
|
+
).toEqual({ a: { b: "[Object]" } });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("string truncation", () => {
|
|
140
|
+
it("should truncate strings longer than maxStringLength", () => {
|
|
141
|
+
expect(fmt.value("abcdefghij", { maxStringLength: 4 })).toBe("abcd…(+6 chars)");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should leave strings exactly at the limit alone", () => {
|
|
145
|
+
expect(fmt.value("abcd", { maxStringLength: 4 })).toBe("abcd");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should leave strings shorter than the limit alone", () => {
|
|
149
|
+
expect(fmt.value("hi", { maxStringLength: 10 })).toBe("hi");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("fmt.stringify", () => {
|
|
155
|
+
it("should render a flat object as pretty-printed JSON", () => {
|
|
156
|
+
expect(fmt.stringify({ a: 1, b: "two" })).toBe(
|
|
157
|
+
`{
|
|
158
|
+
"a": 1,
|
|
159
|
+
"b": "two"
|
|
160
|
+
}`,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should render a nested object with indented children", () => {
|
|
165
|
+
expect(fmt.stringify({ a: { b: [1, 2] } })).toBe(
|
|
166
|
+
`{
|
|
167
|
+
"a": {
|
|
168
|
+
"b": [
|
|
169
|
+
1,
|
|
170
|
+
2
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
}`,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("should render a truncated string inline", () => {
|
|
178
|
+
expect(fmt.stringify("abcdefghij", { maxStringLength: 4 })).toBe(
|
|
179
|
+
`"abcd…(+6 chars)"`,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("should render a truncated array inline", () => {
|
|
184
|
+
expect(fmt.stringify([1, 2, 3, 4, 5], { maxArrayLength: 2 })).toBe(
|
|
185
|
+
`[
|
|
186
|
+
1,
|
|
187
|
+
2,
|
|
188
|
+
"…(+3 more)"
|
|
189
|
+
]`,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should render undefined as its bracketed tag", () => {
|
|
194
|
+
expect(fmt.stringify(undefined)).toBe(`"[undefined]"`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should render null as the JSON null literal", () => {
|
|
198
|
+
expect(fmt.stringify(null)).toBe("null");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("should tolerate circular references without throwing", () => {
|
|
202
|
+
const circular: Record<string, unknown> = {};
|
|
203
|
+
circular.self = circular;
|
|
204
|
+
expect(() => fmt.stringify(circular)).not.toThrow();
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/fmt/value.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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 { narrow } from "@/narrow";
|
|
11
|
+
|
|
12
|
+
export interface Options {
|
|
13
|
+
maxStringLength?: number;
|
|
14
|
+
maxArrayLength?: number;
|
|
15
|
+
maxDepth?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_STRING_LENGTH = 200;
|
|
19
|
+
const DEFAULT_MAX_ARRAY_LENGTH = 10;
|
|
20
|
+
const DEFAULT_MAX_DEPTH = 8;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Produces a safe, JSON-friendly representation of an arbitrary value for logging and
|
|
24
|
+
* display. Strings, arrays, and object depth are capped; non-plain values such as
|
|
25
|
+
* `Date`, `Error`, functions, and symbols are rendered as short bracketed tags.
|
|
26
|
+
*
|
|
27
|
+
* Intended for user-facing error messages, debug output, and any situation where the
|
|
28
|
+
* input may be untrusted or unbounded in size. The output is always structurally
|
|
29
|
+
* cloneable and safe to JSON.stringify.
|
|
30
|
+
*/
|
|
31
|
+
export const value = (input: unknown, options: Options = {}): unknown => {
|
|
32
|
+
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH;
|
|
33
|
+
const maxArrayLength = options.maxArrayLength ?? DEFAULT_MAX_ARRAY_LENGTH;
|
|
34
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
35
|
+
|
|
36
|
+
const walk = (v: unknown, depth: number): unknown => {
|
|
37
|
+
if (v === null) return null;
|
|
38
|
+
if (v === undefined) return "[undefined]";
|
|
39
|
+
const t = typeof v;
|
|
40
|
+
if (t === "string") {
|
|
41
|
+
const s = v as string;
|
|
42
|
+
if (s.length > maxStringLength)
|
|
43
|
+
return `${s.slice(0, maxStringLength)}…(+${s.length - maxStringLength} chars)`;
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
if (t === "number" || t === "boolean") return v;
|
|
47
|
+
if (t === "bigint") return `${(v as bigint).toString()}n`;
|
|
48
|
+
if (t === "symbol" || t === "function") return `[${t}]`;
|
|
49
|
+
if (Array.isArray(v)) {
|
|
50
|
+
if (depth >= maxDepth) return `[Array(${v.length})]`;
|
|
51
|
+
const items: unknown[] = v
|
|
52
|
+
.slice(0, maxArrayLength)
|
|
53
|
+
.map((item) => walk(item, depth + 1));
|
|
54
|
+
if (v.length > maxArrayLength)
|
|
55
|
+
items.push(`…(+${v.length - maxArrayLength} more)`);
|
|
56
|
+
return items;
|
|
57
|
+
}
|
|
58
|
+
if (narrow.isPlainObject(v)) {
|
|
59
|
+
if (depth >= maxDepth) return "[Object]";
|
|
60
|
+
const out: Record<string, unknown> = {};
|
|
61
|
+
for (const [k, val] of Object.entries(v)) out[k] = walk(val, depth + 1);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
if (v instanceof Date) return v.toISOString();
|
|
65
|
+
if (v instanceof Error) return `[Error: ${v.message}]`;
|
|
66
|
+
try {
|
|
67
|
+
return `[${Object.prototype.toString.call(v)}]`;
|
|
68
|
+
} catch {
|
|
69
|
+
return "[unknown]";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return walk(input, 0);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns a pretty-printed JSON rendering of `input` after passing it through
|
|
78
|
+
* {@link value}. Safe for untrusted or unbounded data: `value()` always returns a
|
|
79
|
+
* JSON-compatible structure (primitives, arrays, plain objects, strings), breaks
|
|
80
|
+
* cycles at the depth cap, and converts bigints/symbols/functions to strings.
|
|
81
|
+
*/
|
|
82
|
+
export const stringify = (input: unknown, options: Options = {}): string =>
|
|
83
|
+
JSON.stringify(value(input, options), null, 2);
|
package/src/index.ts
CHANGED
|
@@ -67,4 +67,47 @@ describe("narrow", () => {
|
|
|
67
67
|
expect(narrow.isObject(/test/)).toBe(true);
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("isPlainObject", () => {
|
|
72
|
+
it("should return true for object literals", () => {
|
|
73
|
+
expect(narrow.isPlainObject({})).toBe(true);
|
|
74
|
+
expect(narrow.isPlainObject({ a: 1 })).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should return true for objects with a null prototype", () => {
|
|
78
|
+
expect(narrow.isPlainObject(Object.create(null))).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should return false for arrays", () => {
|
|
82
|
+
expect(narrow.isPlainObject([])).toBe(false);
|
|
83
|
+
expect(narrow.isPlainObject([1, 2])).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should return false for null and undefined", () => {
|
|
87
|
+
expect(narrow.isPlainObject(null)).toBe(false);
|
|
88
|
+
expect(narrow.isPlainObject(undefined)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should return false for primitives", () => {
|
|
92
|
+
expect(narrow.isPlainObject(0)).toBe(false);
|
|
93
|
+
expect(narrow.isPlainObject("string")).toBe(false);
|
|
94
|
+
expect(narrow.isPlainObject(true)).toBe(false);
|
|
95
|
+
expect(narrow.isPlainObject(Symbol("x"))).toBe(false);
|
|
96
|
+
expect(narrow.isPlainObject(42n)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should return false for class instances", () => {
|
|
100
|
+
class Custom {}
|
|
101
|
+
expect(narrow.isPlainObject(new Custom())).toBe(false);
|
|
102
|
+
expect(narrow.isPlainObject(new Date())).toBe(false);
|
|
103
|
+
expect(narrow.isPlainObject(new Map())).toBe(false);
|
|
104
|
+
expect(narrow.isPlainObject(new Error())).toBe(false);
|
|
105
|
+
expect(narrow.isPlainObject(/regex/)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should return false for functions", () => {
|
|
109
|
+
expect(narrow.isPlainObject(() => 1)).toBe(false);
|
|
110
|
+
expect(narrow.isPlainObject(async () => 1)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
70
113
|
});
|
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
|
+
};
|