@pithy-sh/core 0.1.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/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { createLogger, type Logger } from "./logger";
|
|
5
|
+
import type { LogLevel, LogRecord } from "./record";
|
|
6
|
+
import { serializeRecord } from "./record";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mode 1 — local diagnostics. One unified diagnostic layer for the `pithy` CLI process and a Worker
|
|
10
|
+
* running under `pithy dev` / `wrangler dev`. Human-readable and (optionally) colorized for a person at
|
|
11
|
+
* a terminal, or a `--json` structured line stream for agents and CI. This is *diagnostic logging only*
|
|
12
|
+
* — interactive CLI UX (prompts, spinners, `PithyError` rendering) stays on the CLI's `style.ts`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Level-keyed color functions the local adapter paints with. Injected, not hard-coded: core stays free
|
|
17
|
+
* of ANSI, and the CLI passes its `style.ts` seam so all CLI color still flows through one place. Every
|
|
18
|
+
* entry is optional; a missing one renders plain.
|
|
19
|
+
*/
|
|
20
|
+
export interface LocalPalette {
|
|
21
|
+
debug?: (text: string) => string;
|
|
22
|
+
info?: (text: string) => string;
|
|
23
|
+
warn?: (text: string) => string;
|
|
24
|
+
error?: (text: string) => string;
|
|
25
|
+
/** Dimmed secondary text — the namespace, fields, and error detail. */
|
|
26
|
+
dim?: (text: string) => string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Options for {@link createLocalLogger}. */
|
|
30
|
+
export interface LocalLoggerOptions {
|
|
31
|
+
/** The threshold. Defaults to `debug` — local diagnostics are verbose by default. */
|
|
32
|
+
level?: LogLevel;
|
|
33
|
+
/** Emit each record as a `JSON.stringify` line instead of the human format — the agent/CI stream. */
|
|
34
|
+
json?: boolean;
|
|
35
|
+
/** Where a formatted line goes. Defaults to `console.error` (stderr under Node), so diagnostics never pollute a command's stdout. */
|
|
36
|
+
write?: (line: string) => void;
|
|
37
|
+
/** Optional color functions (the CLI injects its `style.ts` seam). Omitted → plain text. */
|
|
38
|
+
palette?: LocalPalette;
|
|
39
|
+
/** The clock, injectable for tests. */
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Render one field value: strings verbatim, everything else as compact JSON — never throwing on a cycle/BigInt. */
|
|
44
|
+
function renderValue(value: unknown): string {
|
|
45
|
+
if (typeof value === "string") return value;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.stringify(value) ?? String(value);
|
|
48
|
+
} catch {
|
|
49
|
+
return String(value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `key=value key=value` for the record's fields, in insertion order. */
|
|
54
|
+
function renderFields(fields: Record<string, unknown>): string {
|
|
55
|
+
return Object.entries(fields)
|
|
56
|
+
.map(([key, value]) => `${key}=${renderValue(value)}`)
|
|
57
|
+
.join(" ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Format one record for a terminal: `LEVEL (name) msg key=val … code detail`, colorized when a palette is set. */
|
|
61
|
+
function renderHuman(record: LogRecord, palette?: LocalPalette): string {
|
|
62
|
+
const paint = palette?.[record.level] ?? ((text: string) => text);
|
|
63
|
+
const dim = palette?.dim ?? ((text: string) => text);
|
|
64
|
+
const parts = [paint(record.level.toUpperCase())];
|
|
65
|
+
if (record.name) parts.push(dim(`(${record.name})`));
|
|
66
|
+
parts.push(record.msg);
|
|
67
|
+
if (record.fields) parts.push(dim(renderFields(record.fields)));
|
|
68
|
+
if (record.error) {
|
|
69
|
+
const detail = record.error.detail ? ` ${record.error.detail}` : "";
|
|
70
|
+
parts.push(dim(`${record.error.code}${detail}`));
|
|
71
|
+
}
|
|
72
|
+
return parts.join(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a Mode 1 local logger. Same interface as every other Pithy logger; the sink either renders a
|
|
77
|
+
* human line (default, colorized when a palette is injected) or a `--json` structured line. Writes to
|
|
78
|
+
* `stderr` by default so a command's machine-readable stdout stays clean.
|
|
79
|
+
*/
|
|
80
|
+
export function createLocalLogger(options: LocalLoggerOptions = {}): Logger {
|
|
81
|
+
// The one sanctioned `console` in the local adapter: this is the adapter, so console is the transport,
|
|
82
|
+
// not a shortcut past the `Logger` seam. Default to console.error — stderr under Node (the CLI), the
|
|
83
|
+
// console elsewhere — so core stays free of `process`. The CLI passes its own `write` when it wants raw
|
|
84
|
+
// `process.stderr`.
|
|
85
|
+
const write = options.write ?? ((line: string) => console.error(line));
|
|
86
|
+
return createLogger({
|
|
87
|
+
level: options.level ?? "debug",
|
|
88
|
+
now: options.now,
|
|
89
|
+
sink: (record) => write(options.json ? serializeRecord(record) : renderHuman(record, options.palette)),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ErrorPayload } from "../error/payload";
|
|
5
|
+
import { PithyError } from "../error/pithyError";
|
|
6
|
+
import { LOG_LEVEL_ORDER, type LogFields, type LogLevel, type LogRecord } from "./record";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `Logger` seam. Four levels, each taking a message plus **structured fields** (not a formatted
|
|
10
|
+
* string), and `child(name)` to derive a namespaced sub-logger. Shared across runtimes: the same
|
|
11
|
+
* interface backs the `pithy` CLI process, a Worker running locally, and a deployed Worker — only the
|
|
12
|
+
* adapter behind it differs. Resolve it from the request context (`c.var.log`) or the CLI process
|
|
13
|
+
* logger; never reach for `console`.
|
|
14
|
+
*
|
|
15
|
+
* The reserved `error` field on any call may carry a `PithyError` or `ErrorPayload` — the engine lifts
|
|
16
|
+
* it into the record's `error`, `detail` and all. A log is internal; this is the inverse of the HTTP
|
|
17
|
+
* codec, which strips `detail`. The logger must never be wired to a client-facing surface.
|
|
18
|
+
*/
|
|
19
|
+
export interface Logger {
|
|
20
|
+
/** Dev-time diagnostics. Dropped unless the logger's level is `debug`. */
|
|
21
|
+
debug(msg: string, fields?: LogFields): void;
|
|
22
|
+
/** Normal operational events. */
|
|
23
|
+
info(msg: string, fields?: LogFields): void;
|
|
24
|
+
/** A recoverable problem worth attention. */
|
|
25
|
+
warn(msg: string, fields?: LogFields): void;
|
|
26
|
+
/** A failure that was observed. Pass `{ error }` (a PithyError) to carry its full payload. */
|
|
27
|
+
error(msg: string, fields?: LogFields): void;
|
|
28
|
+
/**
|
|
29
|
+
* A namespaced sub-logger. `name` composes as `parent:child`; bound `fields` merge into every record
|
|
30
|
+
* the child emits. Omit `name` (pass `undefined`) to bind fields only — how request-correlation
|
|
31
|
+
* attaches `request`/`method`/`path` without renaming the logger.
|
|
32
|
+
*/
|
|
33
|
+
child(name?: string, fields?: LogFields): Logger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A sink receives a finished {@link LogRecord}. Adapters supply one; the engine builds records and calls it. */
|
|
37
|
+
export type LogSink = (record: LogRecord) => void;
|
|
38
|
+
|
|
39
|
+
/** Inputs to {@link createLogger}: the level threshold, the sink, and optional bound name/fields/clock. */
|
|
40
|
+
export interface CreateLoggerOptions {
|
|
41
|
+
/** The threshold; records below it are dropped. */
|
|
42
|
+
level: LogLevel;
|
|
43
|
+
/** Where finished records go. */
|
|
44
|
+
sink: LogSink;
|
|
45
|
+
/** A starting namespace for this logger, if any. */
|
|
46
|
+
name?: string;
|
|
47
|
+
/** Fields bound onto every record this logger emits. */
|
|
48
|
+
fields?: LogFields;
|
|
49
|
+
/** The clock, injectable for tests. Defaults to `Date.now`. */
|
|
50
|
+
now?: () => number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Internal, fully-resolved logger state — `now` and `level` always set. */
|
|
54
|
+
interface LoggerState {
|
|
55
|
+
level: LogLevel;
|
|
56
|
+
sink: LogSink;
|
|
57
|
+
name?: string;
|
|
58
|
+
fields?: LogFields;
|
|
59
|
+
now: () => number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Duck-type an `ErrorPayload`: a **namespaced** `code` (`domain/reason`) plus an HTTP-range `status`.
|
|
64
|
+
* Cheap — no per-call Zod parse. The `/` and status-range checks keep an ordinary field that happens to
|
|
65
|
+
* be named `error` (e.g. `{ code: "retry", status: 3 }`) from being mistaken for a payload and lifted.
|
|
66
|
+
*/
|
|
67
|
+
function isErrorPayloadLike(value: unknown): value is ErrorPayload {
|
|
68
|
+
if (typeof value !== "object" || value === null) return false;
|
|
69
|
+
const { code, status } = value as { code?: unknown; status?: unknown };
|
|
70
|
+
return (
|
|
71
|
+
typeof code === "string" &&
|
|
72
|
+
code.includes("/") &&
|
|
73
|
+
typeof status === "number" &&
|
|
74
|
+
Number.isInteger(status) &&
|
|
75
|
+
status >= 100 &&
|
|
76
|
+
status <= 599
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build a logger over `state`: level-filters, merges bound + call fields, lifts a carried error. */
|
|
81
|
+
function makeLogger(state: LoggerState): Logger {
|
|
82
|
+
const emit = (level: LogLevel, msg: string, callFields?: LogFields): void => {
|
|
83
|
+
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[state.level]) return;
|
|
84
|
+
const merged: LogFields = { ...state.fields, ...callFields };
|
|
85
|
+
|
|
86
|
+
// Lift a reserved `error` field into the record's typed `error` — a PithyError contributes its full
|
|
87
|
+
// payload (detail included); a raw ErrorPayload passes through. Everything else stays a plain field.
|
|
88
|
+
let error: ErrorPayload | undefined;
|
|
89
|
+
const carried = merged.error;
|
|
90
|
+
if (carried instanceof PithyError) {
|
|
91
|
+
error = carried.payload;
|
|
92
|
+
delete merged.error;
|
|
93
|
+
} else if (isErrorPayloadLike(carried)) {
|
|
94
|
+
error = carried;
|
|
95
|
+
delete merged.error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const record: LogRecord = { level, msg, time: state.now() };
|
|
99
|
+
if (state.name) record.name = state.name;
|
|
100
|
+
if (Object.keys(merged).length > 0) record.fields = merged;
|
|
101
|
+
if (error) record.error = error;
|
|
102
|
+
state.sink(record);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
debug: (msg, fields) => emit("debug", msg, fields),
|
|
107
|
+
info: (msg, fields) => emit("info", msg, fields),
|
|
108
|
+
warn: (msg, fields) => emit("warn", msg, fields),
|
|
109
|
+
error: (msg, fields) => emit("error", msg, fields),
|
|
110
|
+
child: (name, fields) =>
|
|
111
|
+
makeLogger({
|
|
112
|
+
...state,
|
|
113
|
+
name: name ? (state.name ? `${state.name}:${name}` : name) : state.name,
|
|
114
|
+
fields: { ...state.fields, ...fields },
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The shared logger engine. Both adapters build on it: they supply a {@link LogSink} and a level, and
|
|
121
|
+
* this handles level filtering, `child` namespacing, field merging, and error lifting — one place, so
|
|
122
|
+
* every adapter behaves identically.
|
|
123
|
+
*/
|
|
124
|
+
export function createLogger(options: CreateLoggerOptions): Logger {
|
|
125
|
+
return makeLogger({
|
|
126
|
+
level: options.level,
|
|
127
|
+
sink: options.sink,
|
|
128
|
+
name: options.name,
|
|
129
|
+
fields: options.fields,
|
|
130
|
+
now: options.now ?? (() => Date.now()),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The safe default: a logger that drops every record. Shipped so a capability that does nothing still
|
|
136
|
+
* has a `c.var.log` to call, and a KV store built without a logger still logs to *somewhere*. Both
|
|
137
|
+
* adapters replace it; nothing has to null-check `log`.
|
|
138
|
+
*/
|
|
139
|
+
export const noopLogger: Logger = {
|
|
140
|
+
debug: () => {},
|
|
141
|
+
info: () => {},
|
|
142
|
+
warn: () => {},
|
|
143
|
+
error: () => {},
|
|
144
|
+
child: () => noopLogger,
|
|
145
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ErrorPayload } from "../error/payload";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The logger object model. A log call is a **record**, not a formatted line — `{ level, msg, ...fields }`
|
|
9
|
+
* — so both adapters (local diagnostics, CF-native) serialize the same structured shape rather than
|
|
10
|
+
* re-parsing text. The schema IS the documentation: every field carries a `.describe()`, like any other
|
|
11
|
+
* Pithy boundary. Log copy follows the brand voice — the `msg` is one pithy line; data lives in `fields`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The four severities, low to high. `debug` is dev diagnostics; `error` is a failure that was observed. */
|
|
15
|
+
export const LogLevel = z
|
|
16
|
+
.enum(["debug", "info", "warn", "error"])
|
|
17
|
+
.describe("Log severity, low to high: `debug` < `info` < `warn` < `error`. The threshold a logger filters on.");
|
|
18
|
+
export type LogLevel = z.output<typeof LogLevel>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Numeric rank per level, for threshold filtering — a logger drops any record ranked below its level.
|
|
22
|
+
* Not a schema (it is a lookup table), so the meta-test that polices `.describe()` skips it.
|
|
23
|
+
*/
|
|
24
|
+
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Structured fields carried on a record — the data half of a log. Arbitrary JSON-ish values keyed by
|
|
28
|
+
* name (`{ userId, elapsed, request }`); an adapter renders or serializes them. The reserved key
|
|
29
|
+
* `error` may carry a `PithyError` or `ErrorPayload`; the engine lifts it into {@link LogRecord.error}.
|
|
30
|
+
*/
|
|
31
|
+
export const LogFields = z
|
|
32
|
+
.record(z.string(), z.unknown())
|
|
33
|
+
.describe("Structured, arbitrary fields carried on a log record — the queryable data half of a log call.");
|
|
34
|
+
export type LogFields = z.output<typeof LogFields>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One finished log record — what an adapter's sink receives. This is the on-the-wire structured shape
|
|
38
|
+
* CF Workers Logs indexes per line and the local adapter renders. `error` carries the **full**
|
|
39
|
+
* `ErrorPayload` including `detail`: a log is an internal surface (the same side of the boundary as
|
|
40
|
+
* audit `detail`), the inverse of the HTTP codec which strips it. A logger is never wired to a client.
|
|
41
|
+
*/
|
|
42
|
+
export const LogRecord = z
|
|
43
|
+
.object({
|
|
44
|
+
level: LogLevel.describe("The severity of this record."),
|
|
45
|
+
msg: z.string().describe("The one-line, brand-voice message. No data — data lives in `fields`."),
|
|
46
|
+
time: z.number().int().describe("When the record was made, as a millisecond epoch."),
|
|
47
|
+
name: z.string().nullish().describe("The emitting logger's namespace (from `child(name)`), if any."),
|
|
48
|
+
fields: LogFields.nullish().describe("Structured fields carried on this record, if any were supplied."),
|
|
49
|
+
error: ErrorPayload.nullish().describe(
|
|
50
|
+
"The full error payload — `detail` included — when the call carried a PithyError. Internal only.",
|
|
51
|
+
),
|
|
52
|
+
})
|
|
53
|
+
.describe("One structured log record: an adapter's sink serializes or renders exactly this shape.");
|
|
54
|
+
export type LogRecord = z.output<typeof LogRecord>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A JSON replacer that keeps serialization total: `BigInt` becomes a string, and a repeated object
|
|
58
|
+
* reference (a cycle) becomes `"[Circular]"`. A logger must never throw — an unserializable field in a
|
|
59
|
+
* log call must not crash the caller — so both adapters serialize through {@link serializeRecord}.
|
|
60
|
+
*/
|
|
61
|
+
function safeReplacer(): (key: string, value: unknown) => unknown {
|
|
62
|
+
const seen = new WeakSet<object>();
|
|
63
|
+
return (_key, value) => {
|
|
64
|
+
if (typeof value === "bigint") return value.toString();
|
|
65
|
+
if (typeof value === "object" && value !== null) {
|
|
66
|
+
if (seen.has(value)) return "[Circular]";
|
|
67
|
+
seen.add(value);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Serialize a record to one JSON line, tolerating cycles and `BigInt` so a log call is crash-proof. If
|
|
75
|
+
* even the safe pass fails, fall back to the always-serializable core fields — never throw.
|
|
76
|
+
*/
|
|
77
|
+
export function serializeRecord(record: LogRecord): string {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.stringify(record, safeReplacer());
|
|
80
|
+
} catch {
|
|
81
|
+
return JSON.stringify({ level: record.level, msg: record.msg, time: record.time });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { createLogger, type Logger } from "./logger";
|
|
5
|
+
import type { LogFields, LogLevel, LogRecord } from "./record";
|
|
6
|
+
import { serializeRecord } from "./record";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mode 2 — CF-native structured logs, for a deployed Worker. Each log emits as **one per-line
|
|
10
|
+
* structured record** Cloudflare Workers Logs indexes and can query — not one buried per-request blob.
|
|
11
|
+
* With `observability.enabled` on in the generated `wrangler.jsonc`, these are queryable in the
|
|
12
|
+
* dashboard with zero adopter setup. The `transport` hook fans the same records to a tail-consumer
|
|
13
|
+
* Worker or Logpush without touching any call site.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Options for {@link createWorkerLogger}. */
|
|
17
|
+
export interface WorkerLoggerOptions {
|
|
18
|
+
/** The threshold. Defaults to `info` — a deployed Worker is quieter than local dev. */
|
|
19
|
+
level?: LogLevel;
|
|
20
|
+
/**
|
|
21
|
+
* The tail/Logpush plug point: called with every finished record after it is emitted, so records can
|
|
22
|
+
* fan to a tail-consumer or Logpush unchanged. Enabling it changes no log call site.
|
|
23
|
+
*/
|
|
24
|
+
transport?: (record: LogRecord) => void;
|
|
25
|
+
/**
|
|
26
|
+
* How a record reaches Workers Logs. Defaults to a single crash-proof JSON line via `console.log`,
|
|
27
|
+
* which Workers Logs indexes as structured JSON. Injectable for tests.
|
|
28
|
+
*/
|
|
29
|
+
emit?: (record: LogRecord) => void;
|
|
30
|
+
/** Fields bound onto every record (e.g. `env`, `version`). */
|
|
31
|
+
fields?: LogFields;
|
|
32
|
+
/** A starting namespace. */
|
|
33
|
+
name?: string;
|
|
34
|
+
/** The clock, injectable for tests. */
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a Mode 2 CF-native logger. The sink emits one structured line to Workers Logs, then hands the
|
|
40
|
+
* same record to `transport` when one is configured. The record shape is unchanged whether or not a
|
|
41
|
+
* transport is attached — tail/Logpush readiness is by construction.
|
|
42
|
+
*/
|
|
43
|
+
export function createWorkerLogger(options: WorkerLoggerOptions = {}): Logger {
|
|
44
|
+
// The one sanctioned `console` in a Worker: this is the adapter, so console is the transport, not a
|
|
45
|
+
// shortcut past it. `console.log` *is* the Workers Logs ingest API — a structured line written here is
|
|
46
|
+
// what the dashboard indexes. Every other module logs through the `Logger` seam and lands here.
|
|
47
|
+
const emit = options.emit ?? ((record: LogRecord) => console.log(serializeRecord(record)));
|
|
48
|
+
const { transport } = options;
|
|
49
|
+
return createLogger({
|
|
50
|
+
level: options.level ?? "info",
|
|
51
|
+
name: options.name,
|
|
52
|
+
fields: options.fields,
|
|
53
|
+
now: options.now,
|
|
54
|
+
sink: (record) => {
|
|
55
|
+
emit(record);
|
|
56
|
+
transport?.(record);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The per-request correlation fields bound onto every Worker-side record. Resolved from the request. */
|
|
62
|
+
export interface RequestContext {
|
|
63
|
+
/** A stable id for this request (the CF ray id, or a generated uuid). */
|
|
64
|
+
request: string;
|
|
65
|
+
/** The HTTP method. */
|
|
66
|
+
method: string;
|
|
67
|
+
/** The request path. */
|
|
68
|
+
path: string;
|
|
69
|
+
/** The environment name (`dev` | `staging` | `production`), from config. */
|
|
70
|
+
env: string;
|
|
71
|
+
/** The deployed Worker version id, when the version-metadata binding is present. */
|
|
72
|
+
version?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Derive a per-request logger by binding {@link RequestContext} onto `base` — so every record the
|
|
77
|
+
* handler emits auto-carries `request`/`method`/`path`/`env`/`version` with no caller effort. Uses the
|
|
78
|
+
* nameless `child` form, leaving the logger's namespace free for a capability to claim.
|
|
79
|
+
*/
|
|
80
|
+
export function bindRequestContext(base: Logger, context: RequestContext): Logger {
|
|
81
|
+
const fields: LogFields = {
|
|
82
|
+
request: context.request,
|
|
83
|
+
method: context.method,
|
|
84
|
+
path: context.path,
|
|
85
|
+
env: context.env,
|
|
86
|
+
};
|
|
87
|
+
if (context.version !== undefined) fields.version = context.version;
|
|
88
|
+
return base.child(undefined, fields);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The per-run correlation fields bound onto every record a Workflow emits. Resolved from the run's event and env. */
|
|
92
|
+
export interface WorkflowContext {
|
|
93
|
+
/** The deployed Workflow name, from `event.workflowName`. */
|
|
94
|
+
workflow: string;
|
|
95
|
+
/** This run's instance id, from `event.instanceId` — what the dashboard and `wrangler workflows` key on. */
|
|
96
|
+
instance: string;
|
|
97
|
+
/** The environment name (`dev` | `staging` | `prod`), from the host's `ENVIRONMENT` var. */
|
|
98
|
+
env: string;
|
|
99
|
+
/** The deployed Worker version id, when the version-metadata binding is present. */
|
|
100
|
+
version?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Derive a per-run logger by binding {@link WorkflowContext} onto `base` — the Workflow peer of
|
|
105
|
+
* {@link bindRequestContext}, so a durable run correlates the way a request already does. A run has no
|
|
106
|
+
* method or path; it has an instance, and that is the id anyone reading Workflows Logs searches by. Uses
|
|
107
|
+
* the nameless `child` form, leaving the logger's namespace free for a capability to claim.
|
|
108
|
+
*/
|
|
109
|
+
export function bindWorkflowContext(base: Logger, context: WorkflowContext): Logger {
|
|
110
|
+
const fields: LogFields = {
|
|
111
|
+
workflow: context.workflow,
|
|
112
|
+
instance: context.instance,
|
|
113
|
+
env: context.env,
|
|
114
|
+
};
|
|
115
|
+
if (context.version !== undefined) fields.version = context.version;
|
|
116
|
+
return base.child(undefined, fields);
|
|
117
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database, D1PreparedStatement } from "@cloudflare/workers-types";
|
|
5
|
+
import type { CompiledQuery, DatabaseConnection, Driver, QueryResult, TransactionSettings } from "kysely";
|
|
6
|
+
import { CamelCasePlugin, Kysely } from "kysely";
|
|
7
|
+
import type { Migration, MigrationProvider } from "kysely/migration";
|
|
8
|
+
import { D1MigrationDialect } from "./bookkeeping";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* **One round trip per migration, instead of one per statement.**
|
|
12
|
+
*
|
|
13
|
+
* `kysely-d1` executes every compiled query as its own `prepare().bind().all()`, so a capability's
|
|
14
|
+
* `0001_init` — nineteen tables and thirty-eight indexes, composed — cost fifty-seven hops to D1.
|
|
15
|
+
* Measured against a real composed application in the Workers runtime: 1,028ms and 68 round trips to
|
|
16
|
+
* migrate, 2,041ms per drop-and-rebuild. An adopter following the kit's own advice (Workers-runtime
|
|
17
|
+
* tests against real D1, not mocks) pays that once per test that needs a clean database; in
|
|
18
|
+
* `pithy-sh/dashboard` it was 78% of total wall time, and the setup floor it left put ordinary test
|
|
19
|
+
* bodies within reach of the 5,000ms timeout (`pithy-sh/pithy#368`).
|
|
20
|
+
*
|
|
21
|
+
* `d1.batch()` sends many statements in one round trip, in order, in one implicit transaction. A
|
|
22
|
+
* migration's statements are already ordered and already a unit, which is the shape `batch` exists
|
|
23
|
+
* for. So a migration body runs against a Kysely whose driver **queues** its statements and sends
|
|
24
|
+
* them as one batch when the body returns.
|
|
25
|
+
*
|
|
26
|
+
* ## Only DDL is queued, and that is the whole safety argument
|
|
27
|
+
*
|
|
28
|
+
* A queued statement's result is returned to the migration body before the statement has run, so it
|
|
29
|
+
* cannot carry rows, a row count, or an insert id. Rather than document that as a caveat, the queue
|
|
30
|
+
* only takes statements that **have no such result to carry** — `create`/`drop`/`alter` on a table,
|
|
31
|
+
* index, view, schema or type. Everything else — every select, insert, update, delete, and every raw
|
|
32
|
+
* `sql` template, whose shape is not knowable from its node — **flushes the queue and then executes
|
|
33
|
+
* on its own**, exactly as before. Ordering is therefore preserved unconditionally: a read always
|
|
34
|
+
* sees every statement written before it.
|
|
35
|
+
*
|
|
36
|
+
* The consequence worth stating: a migration that interleaves data with DDL is split into several
|
|
37
|
+
* batches, one per run of consecutive DDL. It is still correct and still faster; it is simply not one
|
|
38
|
+
* transaction. Every migration the kit ships is pure DDL and is one batch.
|
|
39
|
+
*
|
|
40
|
+
* ## Failure semantics, which did change
|
|
41
|
+
*
|
|
42
|
+
* Before: a migration failing at its k-th statement left statements 1..k-1 applied, with no ledger
|
|
43
|
+
* row — a half-applied migration, the thing a chain exists to prevent. After: the batch is the unit,
|
|
44
|
+
* so a failure anywhere in it rolls back all of it and the ledger still records nothing. The
|
|
45
|
+
* migration is all-or-nothing.
|
|
46
|
+
*
|
|
47
|
+
* What did **not** change is anything across a migration boundary. Each `up`/`down` builds its own
|
|
48
|
+
* queue and flushes before returning, so no statement of one migration can share a batch with
|
|
49
|
+
* another's, and the ledger row is written by Kysely's `Migrator` on the ordinary path afterwards.
|
|
50
|
+
* A chain that fails at its third migration still has its first two applied and recorded, and the
|
|
51
|
+
* error still names them. Batching across migrations would make a partial chain unrepresentable in
|
|
52
|
+
* the ledger, which is worse than slow.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compiled-query kinds with no result a migration body could read — the only ones safe to queue.
|
|
57
|
+
* SQLite ignores schemas and types, but the nodes exist and cost nothing to name.
|
|
58
|
+
*/
|
|
59
|
+
const QUEUEABLE_KINDS: ReadonlySet<string> = new Set([
|
|
60
|
+
"CreateTableNode",
|
|
61
|
+
"DropTableNode",
|
|
62
|
+
"CreateIndexNode",
|
|
63
|
+
"DropIndexNode",
|
|
64
|
+
"AlterTableNode",
|
|
65
|
+
"CreateViewNode",
|
|
66
|
+
"DropViewNode",
|
|
67
|
+
"CreateSchemaNode",
|
|
68
|
+
"DropSchemaNode",
|
|
69
|
+
"CreateTypeNode",
|
|
70
|
+
"DropTypeNode",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/** Statements waiting for their one round trip. Ordered; flushed as a single `d1.batch()`. */
|
|
74
|
+
class StatementQueue {
|
|
75
|
+
readonly #database: D1Database;
|
|
76
|
+
#pending: CompiledQuery[] = [];
|
|
77
|
+
|
|
78
|
+
constructor(database: D1Database) {
|
|
79
|
+
this.#database = database;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
add(query: CompiledQuery): void {
|
|
83
|
+
this.#pending.push(query);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Send everything queued, in order, as one batch — and nothing at all when nothing is queued, so a
|
|
88
|
+
* flush is always safe to call. Cleared before the call, so a failed batch is not retried by a
|
|
89
|
+
* later flush.
|
|
90
|
+
*/
|
|
91
|
+
async flush(): Promise<void> {
|
|
92
|
+
if (this.#pending.length === 0) return;
|
|
93
|
+
const pending = this.#pending;
|
|
94
|
+
this.#pending = [];
|
|
95
|
+
const statements: D1PreparedStatement[] = pending.map((query) =>
|
|
96
|
+
this.#database.prepare(query.sql).bind(...query.parameters),
|
|
97
|
+
);
|
|
98
|
+
await this.#database.batch(statements);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Queues what it can, and flushes before anything it cannot — see the module note. */
|
|
103
|
+
class QueueingConnection implements DatabaseConnection {
|
|
104
|
+
readonly #direct: DatabaseConnection;
|
|
105
|
+
readonly #queue: StatementQueue;
|
|
106
|
+
|
|
107
|
+
constructor(direct: DatabaseConnection, queue: StatementQueue) {
|
|
108
|
+
this.#direct = direct;
|
|
109
|
+
this.#queue = queue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async executeQuery<R>(query: CompiledQuery): Promise<QueryResult<R>> {
|
|
113
|
+
if (QUEUEABLE_KINDS.has(query.query.kind)) {
|
|
114
|
+
this.#queue.add(query);
|
|
115
|
+
return { rows: [] };
|
|
116
|
+
}
|
|
117
|
+
await this.#queue.flush();
|
|
118
|
+
return this.#direct.executeQuery<R>(query);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
streamQuery<R>(query: CompiledQuery, chunkSize: number): AsyncIterableIterator<QueryResult<R>> {
|
|
122
|
+
return this.#direct.streamQuery<R>(query, chunkSize);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The stock D1 driver, with every connection wrapped in the queue. Transactions stay unsupported. */
|
|
127
|
+
class QueueingDriver implements Driver {
|
|
128
|
+
readonly #inner: Driver;
|
|
129
|
+
readonly #queue: StatementQueue;
|
|
130
|
+
readonly #inners = new WeakMap<DatabaseConnection, DatabaseConnection>();
|
|
131
|
+
|
|
132
|
+
constructor(inner: Driver, queue: StatementQueue) {
|
|
133
|
+
this.#inner = inner;
|
|
134
|
+
this.#queue = queue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async init(): Promise<void> {
|
|
138
|
+
await this.#inner.init();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async acquireConnection(): Promise<DatabaseConnection> {
|
|
142
|
+
const inner = await this.#inner.acquireConnection();
|
|
143
|
+
const wrapped = new QueueingConnection(inner, this.#queue);
|
|
144
|
+
this.#inners.set(wrapped, inner);
|
|
145
|
+
return wrapped;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async beginTransaction(connection: DatabaseConnection, settings: TransactionSettings): Promise<void> {
|
|
149
|
+
await this.#inner.beginTransaction(this.#unwrap(connection), settings);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async commitTransaction(connection: DatabaseConnection): Promise<void> {
|
|
153
|
+
await this.#inner.commitTransaction(this.#unwrap(connection));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
|
|
157
|
+
await this.#inner.rollbackTransaction(this.#unwrap(connection));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async releaseConnection(connection: DatabaseConnection): Promise<void> {
|
|
161
|
+
await this.#inner.releaseConnection(this.#unwrap(connection));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async destroy(): Promise<void> {
|
|
165
|
+
await this.#inner.destroy();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#unwrap(connection: DatabaseConnection): DatabaseConnection {
|
|
169
|
+
return this.#inners.get(connection) ?? connection;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pithy's D1 dialect — same compiler, same `sqlite_master`-only introspector — driving the queue. */
|
|
174
|
+
class QueueingD1Dialect extends D1MigrationDialect {
|
|
175
|
+
readonly #queue: StatementQueue;
|
|
176
|
+
|
|
177
|
+
constructor(database: D1Database, queue: StatementQueue) {
|
|
178
|
+
super({ database });
|
|
179
|
+
this.#queue = queue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
override createDriver(): Driver {
|
|
183
|
+
return new QueueingDriver(super.createDriver(), this.#queue);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Run one migration body against a queueing Kysely and send its statements as one batch.
|
|
189
|
+
*
|
|
190
|
+
* The `db` Kysely's `Migrator` would have passed is deliberately not used: it is the instance that
|
|
191
|
+
* also writes the ledger, and the ledger row must stay on the ordinary path so that a failed batch
|
|
192
|
+
* leaves no record of having applied. Same binding, same `CamelCasePlugin`, same dialect — a
|
|
193
|
+
* migration body cannot tell the difference except in how many hops it costs.
|
|
194
|
+
*/
|
|
195
|
+
async function batchBody(body: (db: Kysely<unknown>) => Promise<void>, database: D1Database): Promise<void> {
|
|
196
|
+
const queue = new StatementQueue(database);
|
|
197
|
+
const db = new Kysely<unknown>({ dialect: new QueueingD1Dialect(database, queue), plugins: [new CamelCasePlugin()] });
|
|
198
|
+
await body(db);
|
|
199
|
+
await queue.flush();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One migration, both directions batched. A migration with no `down` still has none. */
|
|
203
|
+
function batchMigration(migration: Migration, database: D1Database): Migration {
|
|
204
|
+
const down = migration.down;
|
|
205
|
+
return {
|
|
206
|
+
up: async (): Promise<void> => batchBody((db) => migration.up(db), database),
|
|
207
|
+
...(down ? { down: async (): Promise<void> => batchBody((db) => down(db), database) } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Wrap a provider so every migration it yields applies and reverses in one round trip each.
|
|
213
|
+
*
|
|
214
|
+
* Applied at the runner's seam rather than inside each capability, so a migration author writes
|
|
215
|
+
* ordinary Kysely and gets this for free — including adopters, who are the ones paying for it.
|
|
216
|
+
*/
|
|
217
|
+
export function batchedProvider(provider: MigrationProvider, database: D1Database): MigrationProvider {
|
|
218
|
+
return {
|
|
219
|
+
getMigrations: async (): Promise<Record<string, Migration>> => {
|
|
220
|
+
const migrations = await provider.getMigrations();
|
|
221
|
+
return Object.fromEntries(
|
|
222
|
+
Object.entries(migrations).map(([name, migration]) => [name, batchMigration(migration, database)]),
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|