@somewhatintelligent/kit 0.0.1-beta.1783705886.297c5be
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/package.json +58 -0
- package/src/execution-context/index.ts +16 -0
- package/src/ids/__tests__/index.test.ts +80 -0
- package/src/ids/index.ts +60 -0
- package/src/log/__tests__/http.test.ts +109 -0
- package/src/log/__tests__/index.test.ts +254 -0
- package/src/log/__tests__/instrumented.test.ts +319 -0
- package/src/log/__tests__/scheduled.test.ts +107 -0
- package/src/log/core.ts +230 -0
- package/src/log/http.ts +72 -0
- package/src/log/index.ts +22 -0
- package/src/log/instrumented.ts +191 -0
- package/src/log/scheduled.ts +84 -0
- package/src/react/auth.tsx +63 -0
- package/src/react/index.ts +2 -0
- package/src/react-start/__tests__/dev-envelope.test.ts +153 -0
- package/src/react-start/__tests__/smoke.test.ts +77 -0
- package/src/react-start/auth-provider.tsx +81 -0
- package/src/react-start/client.ts +11 -0
- package/src/react-start/dev-envelope.ts +136 -0
- package/src/react-start/envelope-middleware.ts +135 -0
- package/src/react-start/index.ts +29 -0
- package/src/react-start/logging.ts +70 -0
- package/src/react-start/platform-start-app.ts +382 -0
- package/src/react-start/platform-start-context.ts +25 -0
- package/src/react-start/request-logger.ts +60 -0
- package/src/react-start/service-clients.ts +246 -0
- package/src/request-context/__tests__/index.test.ts +90 -0
- package/src/request-context/index.ts +189 -0
- package/src/roles.ts +25 -0
- package/src/version/__tests__/index.test.ts +140 -0
- package/src/version/index.ts +85 -0
- package/src/version.gen.ts +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@somewhatintelligent/kit",
|
|
3
|
+
"version": "0.0.1-beta.1783705886.297c5be",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/apostolos-geyer/platform.git",
|
|
12
|
+
"directory": "packages/kit"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
"./ids": "./src/ids/index.ts",
|
|
16
|
+
"./roles": "./src/roles.ts",
|
|
17
|
+
"./log": "./src/log/index.ts",
|
|
18
|
+
"./request-context": "./src/request-context/index.ts",
|
|
19
|
+
"./version": "./src/version/index.ts",
|
|
20
|
+
"./react": "./src/react/index.ts",
|
|
21
|
+
"./react-start": "./src/react-start/index.ts",
|
|
22
|
+
"./react-start/client": "./src/react-start/client.ts",
|
|
23
|
+
"./execution-context": "./src/execution-context/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@somewhatintelligent/auth": "^0.0.0",
|
|
34
|
+
"ulidx": "^2.4.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@cloudflare/workers-types": "^4",
|
|
38
|
+
"@tanstack/react-start": "1.168.18",
|
|
39
|
+
"@types/node": "^25.5.2",
|
|
40
|
+
"@types/react": "^19.2.14",
|
|
41
|
+
"react": "19.2.4",
|
|
42
|
+
"react-dom": "19.2.4",
|
|
43
|
+
"typescript": "5.9.2",
|
|
44
|
+
"vitest": "^4.1.10"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@tanstack/react-start": "1.168.18",
|
|
48
|
+
"react": "19.2.4"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@tanstack/react-start": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"react": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
/** Minimal structural view of Cloudflare's ExecutionContext — avoids a
|
|
4
|
+
* @cloudflare/workers-types dependency in @somewhatintelligent/kit. The real
|
|
5
|
+
* ExecutionContext (worker-configuration.d.ts in each worker) is
|
|
6
|
+
* structurally assignable to this. */
|
|
7
|
+
export interface WaitUntilContext {
|
|
8
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const executionContext = new AsyncLocalStorage<WaitUntilContext>();
|
|
12
|
+
|
|
13
|
+
export const runWithExecutionContext = <T>(
|
|
14
|
+
ctx: WaitUntilContext,
|
|
15
|
+
fn: () => Promise<T>,
|
|
16
|
+
): Promise<T> => executionContext.run(ctx, fn);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { decodeTime, id, isValid, ulid } from "../index";
|
|
3
|
+
|
|
4
|
+
describe("ulid", () => {
|
|
5
|
+
test("returns a 26-char Crockford base32 string", () => {
|
|
6
|
+
const value = ulid();
|
|
7
|
+
expect(value).toHaveLength(26);
|
|
8
|
+
expect(value).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("is monotonic within the same millisecond (CF Workers correctness)", () => {
|
|
12
|
+
// Same sync block — Date.now() returns the same value for all calls.
|
|
13
|
+
// Without monotonicFactory, lex order would be random; with it, strictly increasing.
|
|
14
|
+
const batch = Array.from({ length: 100 }, () => ulid());
|
|
15
|
+
const sorted = [...batch].sort();
|
|
16
|
+
expect(batch).toEqual(sorted);
|
|
17
|
+
expect(new Set(batch).size).toBe(100);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("id (prefixed)", () => {
|
|
22
|
+
test("composes prefix + monotonic ulid with hyphen separator", () => {
|
|
23
|
+
const value = id("grant");
|
|
24
|
+
expect(value).toMatch(/^grant-[0-9A-HJKMNP-TV-Z]{26}$/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rejects invalid prefixes", () => {
|
|
28
|
+
expect(() => id("Grant")).toThrow();
|
|
29
|
+
expect(() => id("9bad")).toThrow();
|
|
30
|
+
expect(() => id("toolongprefix1")).toThrow();
|
|
31
|
+
expect(() => id("with-hyphen")).toThrow();
|
|
32
|
+
expect(() => id("")).toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("is monotonic within the same millisecond per prefix", () => {
|
|
36
|
+
const batch = Array.from({ length: 50 }, () => id("act"));
|
|
37
|
+
const sorted = [...batch].sort();
|
|
38
|
+
expect(batch).toEqual(sorted);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("decodeTime", () => {
|
|
43
|
+
test("decodes plain ULID timestamp", () => {
|
|
44
|
+
const before = Date.now();
|
|
45
|
+
const value = ulid();
|
|
46
|
+
const after = Date.now();
|
|
47
|
+
const decoded = decodeTime(value);
|
|
48
|
+
expect(decoded).toBeGreaterThanOrEqual(before);
|
|
49
|
+
expect(decoded).toBeLessThanOrEqual(after);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("decodes prefixed ULID timestamp", () => {
|
|
53
|
+
const before = Date.now();
|
|
54
|
+
const value = id("trk");
|
|
55
|
+
const after = Date.now();
|
|
56
|
+
const decoded = decodeTime(value);
|
|
57
|
+
expect(decoded).toBeGreaterThanOrEqual(before);
|
|
58
|
+
expect(decoded).toBeLessThanOrEqual(after);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("isValid", () => {
|
|
63
|
+
test("accepts plain and prefixed ULIDs", () => {
|
|
64
|
+
expect(isValid(ulid())).toBe(true);
|
|
65
|
+
expect(isValid(id("grant"))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("rejects garbage", () => {
|
|
69
|
+
expect(isValid("not-a-ulid")).toBe(false);
|
|
70
|
+
expect(isValid("")).toBe(false);
|
|
71
|
+
expect(isValid("01HZX5F7ABCDEF123456GHJKM")).toBe(false); // 25 chars
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("expectedPrefix asserts entity match", () => {
|
|
75
|
+
const grantId = id("grant");
|
|
76
|
+
expect(isValid(grantId, "grant")).toBe(true);
|
|
77
|
+
expect(isValid(grantId, "trk")).toBe(false);
|
|
78
|
+
expect(isValid(ulid(), "grant")).toBe(false); // unprefixed value
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/ids/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monotonic ULID factory + entity-prefixed IDs.
|
|
3
|
+
*
|
|
4
|
+
* On Cloudflare Workers, `Date.now()` is pinned at the start of a request and
|
|
5
|
+
* does not advance until the next I/O. Multiple ULIDs minted in the same sync
|
|
6
|
+
* block therefore share a timestamp; without a monotonic factory, each draws
|
|
7
|
+
* an independent random suffix and lex ordering is no longer guaranteed.
|
|
8
|
+
* `ulidx`'s own docs flag plain `ulid()` as unsafe in this runtime — the
|
|
9
|
+
* factory must be a per-isolate singleton, declared once at module scope.
|
|
10
|
+
*/
|
|
11
|
+
import { monotonicFactory, decodeTime as ulidDecodeTime, isValid as ulidIsValid } from "ulidx";
|
|
12
|
+
|
|
13
|
+
const monotonic = monotonicFactory();
|
|
14
|
+
|
|
15
|
+
/** Mint a monotonic ULID (26 chars, Crockford base32). Module-singleton factory. */
|
|
16
|
+
export function ulid(): string {
|
|
17
|
+
return monotonic();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PREFIX_RE = /^[a-z][a-z0-9]{0,9}$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mint a prefixed ID of the form `<prefix>-<monotonic-ulid>`.
|
|
24
|
+
* Prefix must be lowercase alphanumeric, start with a letter, ≤10 chars.
|
|
25
|
+
*
|
|
26
|
+
* Example: `id("grant")` → `"grant-01HZX5F7ABCDEF123456GHJKMN"`.
|
|
27
|
+
*/
|
|
28
|
+
export function id(prefix: string): string {
|
|
29
|
+
if (!PREFIX_RE.test(prefix)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Invalid id prefix: ${prefix}. Must be lowercase alphanumeric, start with a letter, ≤10 chars.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return `${prefix}-${monotonic()}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Decode the timestamp (ms) embedded in a plain or prefixed ULID. */
|
|
38
|
+
export function decodeTime(value: string): number {
|
|
39
|
+
const idx = value.indexOf("-");
|
|
40
|
+
const body = idx === -1 ? value : value.slice(idx + 1);
|
|
41
|
+
return ulidDecodeTime(body);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate that `value` is a (possibly prefixed) ULID.
|
|
46
|
+
* Pass `expectedPrefix` to assert a specific entity prefix.
|
|
47
|
+
*/
|
|
48
|
+
export function isValid(value: string, expectedPrefix?: string): boolean {
|
|
49
|
+
const idx = value.indexOf("-");
|
|
50
|
+
if (expectedPrefix !== undefined) {
|
|
51
|
+
if (idx === -1) return false;
|
|
52
|
+
if (value.slice(0, idx) !== expectedPrefix) return false;
|
|
53
|
+
return ulidIsValid(value.slice(idx + 1));
|
|
54
|
+
}
|
|
55
|
+
if (idx === -1) return ulidIsValid(value);
|
|
56
|
+
return ulidIsValid(value.slice(idx + 1));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Escape hatch — the underlying monotonic factory, for callers that need raw access. */
|
|
60
|
+
export { monotonic };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { requireRequestLog } from "../index";
|
|
3
|
+
import { withRequestLog } from "../http";
|
|
4
|
+
|
|
5
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
6
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
10
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
logSpy.mockRestore();
|
|
15
|
+
errorSpy.mockRestore();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const baseConfig = {
|
|
19
|
+
service: "guestlist",
|
|
20
|
+
resolveContext: (req: Request) => ({
|
|
21
|
+
requestId: req.headers.get("cf-request-id") ?? "fallback",
|
|
22
|
+
actorKind: "anonymous",
|
|
23
|
+
actorId: null,
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("withRequestLog", () => {
|
|
28
|
+
test("emits event=http line with method/path auto-added", async () => {
|
|
29
|
+
const req = new Request("https://example.com/api/sign-in", {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "cf-request-id": "req_42" },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await withRequestLog(baseConfig, req, async () => new Response("ok"));
|
|
35
|
+
|
|
36
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({
|
|
38
|
+
service: "guestlist",
|
|
39
|
+
event: "http",
|
|
40
|
+
operation: "http.post",
|
|
41
|
+
method: "POST",
|
|
42
|
+
path: "/api/sign-in",
|
|
43
|
+
request_id: "req_42",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("default operation derives from method", async () => {
|
|
48
|
+
const get = new Request("https://example.com/x", { method: "GET" });
|
|
49
|
+
const post = new Request("https://example.com/x", { method: "POST" });
|
|
50
|
+
|
|
51
|
+
await withRequestLog(baseConfig, get, async () => new Response());
|
|
52
|
+
await withRequestLog(baseConfig, post, async () => new Response());
|
|
53
|
+
|
|
54
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({ operation: "http.get" });
|
|
55
|
+
expect(logSpy.mock.calls[1]![0]).toMatchObject({ operation: "http.post" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("deriveOperation override produces route-aware op names", async () => {
|
|
59
|
+
const req = new Request("https://example.com/api/upload", { method: "POST" });
|
|
60
|
+
await withRequestLog(
|
|
61
|
+
{
|
|
62
|
+
...baseConfig,
|
|
63
|
+
deriveOperation: (r) => `roadie.${new URL(r.url).pathname.split("/").pop()}`,
|
|
64
|
+
},
|
|
65
|
+
req,
|
|
66
|
+
async () => new Response(),
|
|
67
|
+
);
|
|
68
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({ operation: "roadie.upload" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("middleware can add status + outcome via the builder", async () => {
|
|
72
|
+
const req = new Request("https://example.com/x", { method: "POST" });
|
|
73
|
+
await withRequestLog(baseConfig, req, async (log) => {
|
|
74
|
+
const status = 404;
|
|
75
|
+
log.add({ status });
|
|
76
|
+
log.outcome(`http_${status}`);
|
|
77
|
+
return new Response(null, { status });
|
|
78
|
+
});
|
|
79
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({
|
|
80
|
+
status: 404,
|
|
81
|
+
outcome: "http_404",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("requireRequestLog inside fn captures domain fields", async () => {
|
|
86
|
+
const req = new Request("https://example.com/x", { method: "POST" });
|
|
87
|
+
await withRequestLog(baseConfig, req, async () => {
|
|
88
|
+
requireRequestLog().add({ user_id: "u1", session_age_ms: 12345 });
|
|
89
|
+
return new Response();
|
|
90
|
+
});
|
|
91
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({
|
|
92
|
+
user_id: "u1",
|
|
93
|
+
session_age_ms: 12345,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("throw → internal_error + rethrow", async () => {
|
|
98
|
+
const req = new Request("https://example.com/x", { method: "POST" });
|
|
99
|
+
await expect(
|
|
100
|
+
withRequestLog(baseConfig, req, async () => {
|
|
101
|
+
throw new Error("kaboom");
|
|
102
|
+
}),
|
|
103
|
+
).rejects.toThrow("kaboom");
|
|
104
|
+
expect(errorSpy.mock.calls[0]![0]).toMatchObject({
|
|
105
|
+
outcome: "internal_error",
|
|
106
|
+
error_message: "kaboom",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { describeThrown, getRequestLog, requireRequestLog, withCanonicalLog } from "../index";
|
|
3
|
+
|
|
4
|
+
const baseCtx = {
|
|
5
|
+
service: "test_service",
|
|
6
|
+
event: "rpc",
|
|
7
|
+
operation: "test.op",
|
|
8
|
+
requestId: "req_01",
|
|
9
|
+
actorKind: "user",
|
|
10
|
+
actorId: "user_01",
|
|
11
|
+
callerApp: "test_app",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
15
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
16
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
20
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
21
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
logSpy.mockRestore();
|
|
26
|
+
warnSpy.mockRestore();
|
|
27
|
+
errorSpy.mockRestore();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("withCanonicalLog", () => {
|
|
31
|
+
test("emits one info-level line on normal return with outcome=ok", async () => {
|
|
32
|
+
const value = await withCanonicalLog(baseCtx, async () => 42);
|
|
33
|
+
expect(value).toBe(42);
|
|
34
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
35
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
36
|
+
const line = logSpy.mock.calls[0]![0] as Record<string, unknown>;
|
|
37
|
+
expect(line).toMatchObject({
|
|
38
|
+
service: "test_service",
|
|
39
|
+
event: "rpc",
|
|
40
|
+
operation: "test.op",
|
|
41
|
+
outcome: "ok",
|
|
42
|
+
request_id: "req_01",
|
|
43
|
+
caller_app: "test_app",
|
|
44
|
+
actor_kind: "user",
|
|
45
|
+
actor_id: "user_01",
|
|
46
|
+
});
|
|
47
|
+
expect(typeof line.duration_ms).toBe("number");
|
|
48
|
+
expect(typeof line.time).toBe("string");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("emits at error level when outcome is in errorOutcomes set", async () => {
|
|
52
|
+
await withCanonicalLog(baseCtx, async (log) => {
|
|
53
|
+
log.outcome("backend_unavailable");
|
|
54
|
+
});
|
|
55
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
57
|
+
expect(errorSpy.mock.calls[0]![0]).toMatchObject({ outcome: "backend_unavailable" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("emits at info level for non-error outcomes (e.g. expected client errors)", async () => {
|
|
61
|
+
await withCanonicalLog(baseCtx, async (log) => {
|
|
62
|
+
log.outcome("not_found");
|
|
63
|
+
});
|
|
64
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
66
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({ outcome: "not_found" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("on throw: emits internal_error at error level + rethrows", async () => {
|
|
70
|
+
await expect(
|
|
71
|
+
withCanonicalLog(baseCtx, async () => {
|
|
72
|
+
throw new Error("boom");
|
|
73
|
+
}),
|
|
74
|
+
).rejects.toThrow("boom");
|
|
75
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(errorSpy.mock.calls[0]![0]).toMatchObject({
|
|
77
|
+
outcome: "internal_error",
|
|
78
|
+
error_message: "boom",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("merges builder.add() fields into the line", async () => {
|
|
83
|
+
await withCanonicalLog(baseCtx, async (log) => {
|
|
84
|
+
log.add({ resource_id: "res_01", part_number: 3 });
|
|
85
|
+
});
|
|
86
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({
|
|
87
|
+
resource_id: "res_01",
|
|
88
|
+
part_number: 3,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("strips forbidden fields by exact name and prefix", async () => {
|
|
93
|
+
await withCanonicalLog(baseCtx, async (log) => {
|
|
94
|
+
log.add({
|
|
95
|
+
password: "hunter2",
|
|
96
|
+
cookie: "session=abc",
|
|
97
|
+
R2_BUCKET: "secret",
|
|
98
|
+
S3_ACCESS_KEY_ID: "AKIA...",
|
|
99
|
+
safe_field: "kept",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
const line = logSpy.mock.calls[0]![0] as Record<string, unknown>;
|
|
103
|
+
expect(line.password).toBeUndefined();
|
|
104
|
+
expect(line.cookie).toBeUndefined();
|
|
105
|
+
expect(line.R2_BUCKET).toBeUndefined();
|
|
106
|
+
expect(line.S3_ACCESS_KEY_ID).toBeUndefined();
|
|
107
|
+
expect(line.safe_field).toBe("kept");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("custom errorOutcomes / forbiddenFields override defaults", async () => {
|
|
111
|
+
await withCanonicalLog(
|
|
112
|
+
{
|
|
113
|
+
...baseCtx,
|
|
114
|
+
errorOutcomes: new Set(["custom_failure"]),
|
|
115
|
+
forbiddenFields: new Set(["my_secret"]),
|
|
116
|
+
forbiddenPrefixes: ["INTERNAL_"],
|
|
117
|
+
},
|
|
118
|
+
async (log) => {
|
|
119
|
+
log.add({ my_secret: "x", INTERNAL_KEY: "y", password: "still-here" });
|
|
120
|
+
log.outcome("custom_failure");
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
124
|
+
const line = errorSpy.mock.calls[0]![0] as Record<string, unknown>;
|
|
125
|
+
expect(line.my_secret).toBeUndefined();
|
|
126
|
+
expect(line.INTERNAL_KEY).toBeUndefined();
|
|
127
|
+
expect(line.password).toBe("still-here"); // default list overridden
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("duration_ms reflects elapsed time", async () => {
|
|
131
|
+
await withCanonicalLog(baseCtx, async () => {
|
|
132
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
133
|
+
});
|
|
134
|
+
const line = logSpy.mock.calls[0]![0] as Record<string, unknown>;
|
|
135
|
+
expect(line.duration_ms).toBeGreaterThanOrEqual(15);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("last builder.outcome() call wins", async () => {
|
|
139
|
+
await withCanonicalLog(baseCtx, async (log) => {
|
|
140
|
+
log.outcome("first");
|
|
141
|
+
log.outcome("second");
|
|
142
|
+
});
|
|
143
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({ outcome: "second" });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("AsyncLocalStorage propagation", () => {
|
|
148
|
+
test("getRequestLog() returns the active builder inside scope", async () => {
|
|
149
|
+
let captured: ReturnType<typeof getRequestLog> = null;
|
|
150
|
+
await withCanonicalLog(baseCtx, async () => {
|
|
151
|
+
captured = getRequestLog();
|
|
152
|
+
});
|
|
153
|
+
expect(captured).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("getRequestLog() returns null outside any scope", () => {
|
|
157
|
+
expect(getRequestLog()).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("requireRequestLog() throws outside any scope", () => {
|
|
161
|
+
expect(() => requireRequestLog()).toThrow(/outside any withCanonicalLog scope/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("fields accrued via getRequestLog() in nested awaits land on the line", async () => {
|
|
165
|
+
async function deep() {
|
|
166
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
167
|
+
requireRequestLog().add({ from_deep: true });
|
|
168
|
+
}
|
|
169
|
+
async function middle() {
|
|
170
|
+
requireRequestLog().add({ from_middle: 1 });
|
|
171
|
+
await deep();
|
|
172
|
+
}
|
|
173
|
+
await withCanonicalLog(baseCtx, async () => {
|
|
174
|
+
await middle();
|
|
175
|
+
});
|
|
176
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({
|
|
177
|
+
from_middle: 1,
|
|
178
|
+
from_deep: true,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("outcome set via getRequestLog() in deep call is honored", async () => {
|
|
183
|
+
async function deep() {
|
|
184
|
+
requireRequestLog().outcome("not_found");
|
|
185
|
+
}
|
|
186
|
+
await withCanonicalLog(baseCtx, async () => {
|
|
187
|
+
await deep();
|
|
188
|
+
});
|
|
189
|
+
expect(logSpy.mock.calls[0]![0]).toMatchObject({ outcome: "not_found" });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("nested withCanonicalLog scopes are independent (inner does not leak to outer)", async () => {
|
|
193
|
+
await withCanonicalLog({ ...baseCtx, operation: "outer" }, async (outerLog) => {
|
|
194
|
+
outerLog.add({ at: "outer" });
|
|
195
|
+
await withCanonicalLog({ ...baseCtx, operation: "inner" }, async (innerLog) => {
|
|
196
|
+
innerLog.add({ at: "inner" });
|
|
197
|
+
// requireRequestLog inside inner scope returns inner's builder
|
|
198
|
+
expect(requireRequestLog()).toBe(innerLog);
|
|
199
|
+
});
|
|
200
|
+
// Back in outer scope, requireRequestLog returns outer's builder
|
|
201
|
+
expect(requireRequestLog()).toBe(outerLog);
|
|
202
|
+
});
|
|
203
|
+
expect(logSpy).toHaveBeenCalledTimes(2);
|
|
204
|
+
const innerLine = logSpy.mock.calls.find(
|
|
205
|
+
(c: unknown[]) => (c[0] as Record<string, unknown>).operation === "inner",
|
|
206
|
+
)![0] as Record<string, unknown>;
|
|
207
|
+
const outerLine = logSpy.mock.calls.find(
|
|
208
|
+
(c: unknown[]) => (c[0] as Record<string, unknown>).operation === "outer",
|
|
209
|
+
)![0] as Record<string, unknown>;
|
|
210
|
+
expect(innerLine.at).toBe("inner");
|
|
211
|
+
expect(outerLine.at).toBe("outer");
|
|
212
|
+
// Outer line does NOT carry the inner field
|
|
213
|
+
expect(outerLine.from_inner).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("describeThrown", () => {
|
|
218
|
+
test("Error → message + stack", () => {
|
|
219
|
+
const err = new Error("boom");
|
|
220
|
+
const d = describeThrown(err);
|
|
221
|
+
expect(d.message).toBe("boom");
|
|
222
|
+
expect(d.stack).toBe(err.stack);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("thrown Response → named by status, never '[object Response]'", () => {
|
|
226
|
+
const d = describeThrown(new Response(null, { status: 307 }));
|
|
227
|
+
expect(d.message).toBe("thrown Response (status 307)");
|
|
228
|
+
expect(d.stack).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("plain object → JSON, never '[object Object]'", () => {
|
|
232
|
+
expect(describeThrown({ isNotFound: true }).message).toBe('{"isNotFound":true}');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("primitive → String()", () => {
|
|
236
|
+
expect(describeThrown("nope").message).toBe("nope");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("withCanonicalLog thrown-Response serialization", () => {
|
|
241
|
+
test("a thrown Response emits a readable error_message", async () => {
|
|
242
|
+
await expect(
|
|
243
|
+
withCanonicalLog(baseCtx, async () => {
|
|
244
|
+
throw new Response(null, { status: 307, headers: { location: "/sign-in" } });
|
|
245
|
+
}),
|
|
246
|
+
).rejects.toBeInstanceOf(Response);
|
|
247
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
248
|
+
expect(errorSpy.mock.calls[0]![0]).toMatchObject({
|
|
249
|
+
outcome: "internal_error",
|
|
250
|
+
error_message: "thrown Response (status 307)",
|
|
251
|
+
error_stack: "thrown Response (status 307)",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|