@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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/pithy.manifest.json +74 -0
  4. package/src/address/address.ts +83 -0
  5. package/src/audit/auditEvent.ts +130 -0
  6. package/src/audit/recorder.ts +22 -0
  7. package/src/capability/bindings.ts +196 -0
  8. package/src/capability/capability.ts +555 -0
  9. package/src/capability/client.ts +136 -0
  10. package/src/capability/compose.ts +76 -0
  11. package/src/capability/composition.ts +98 -0
  12. package/src/capability/config.ts +19 -0
  13. package/src/capability/devSecret.ts +42 -0
  14. package/src/capability/manifest.ts +580 -0
  15. package/src/capability/secretOrigin.ts +253 -0
  16. package/src/capability/settings.ts +155 -0
  17. package/src/capability/validateBindings.ts +43 -0
  18. package/src/capability/vanishingKey.ts +92 -0
  19. package/src/cloudflare-test.d.ts +20 -0
  20. package/src/controlPlane/audit/actions.ts +81 -0
  21. package/src/controlPlane/capability.ts +228 -0
  22. package/src/controlPlane/config/config.ts +195 -0
  23. package/src/controlPlane/context.ts +63 -0
  24. package/src/controlPlane/data/connection.ts +123 -0
  25. package/src/controlPlane/data/keyLifecycle.ts +159 -0
  26. package/src/controlPlane/data/replay.ts +39 -0
  27. package/src/controlPlane/data/tables.ts +51 -0
  28. package/src/controlPlane/discovery/adminRoute.ts +250 -0
  29. package/src/controlPlane/discovery/configuration.ts +280 -0
  30. package/src/controlPlane/discovery/drift.ts +100 -0
  31. package/src/controlPlane/discovery/health.ts +213 -0
  32. package/src/controlPlane/discovery/healthSummary.ts +486 -0
  33. package/src/controlPlane/error/errors.ts +125 -0
  34. package/src/controlPlane/http/cors.ts +244 -0
  35. package/src/controlPlane/http/guard.ts +223 -0
  36. package/src/controlPlane/http/handlers.ts +346 -0
  37. package/src/controlPlane/http/responses.ts +92 -0
  38. package/src/controlPlane/http/routes.ts +115 -0
  39. package/src/controlPlane/http/schemas.ts +70 -0
  40. package/src/controlPlane/http/verify.ts +198 -0
  41. package/src/controlPlane/migrations/0001_init.ts +105 -0
  42. package/src/controlPlane/replay/d1Guard.ts +87 -0
  43. package/src/controlPlane/replay/guard.ts +55 -0
  44. package/src/controlPlane/replay/kvGuard.ts +143 -0
  45. package/src/controlPlane/scope/scope.ts +102 -0
  46. package/src/controlPlane/token/base64url.ts +65 -0
  47. package/src/controlPlane/token/claims.ts +151 -0
  48. package/src/controlPlane/token/digest.ts +63 -0
  49. package/src/controlPlane/token/jws.ts +112 -0
  50. package/src/controlPlane/token/mint.ts +93 -0
  51. package/src/controlPlane/wire.ts +138 -0
  52. package/src/createBackend.ts +292 -0
  53. package/src/createEntrypoint.ts +125 -0
  54. package/src/data/boundParameters.ts +197 -0
  55. package/src/data/codecs.ts +160 -0
  56. package/src/data/cursor.ts +127 -0
  57. package/src/data/databases.ts +84 -0
  58. package/src/data/db.ts +53 -0
  59. package/src/data/withD1Retry.ts +176 -0
  60. package/src/entitlement/entitlement.ts +191 -0
  61. package/src/entitlement/gateScan.ts +107 -0
  62. package/src/entitlement/require.ts +199 -0
  63. package/src/env/ambient.ts +67 -0
  64. package/src/env/ci.ts +43 -0
  65. package/src/env/stem.ts +34 -0
  66. package/src/error/cause.ts +208 -0
  67. package/src/error/client.ts +43 -0
  68. package/src/error/extend.ts +135 -0
  69. package/src/error/http.ts +92 -0
  70. package/src/error/payload.ts +2195 -0
  71. package/src/error/pithyError.ts +281 -0
  72. package/src/error/terminal.ts +36 -0
  73. package/src/http/authContext.ts +29 -0
  74. package/src/http/routeContract.ts +115 -0
  75. package/src/http/sameOrigin.ts +67 -0
  76. package/src/http/signedWebhook.ts +415 -0
  77. package/src/http/validation.ts +41 -0
  78. package/src/http/verification.ts +25 -0
  79. package/src/i18n/acceptLanguage.ts +70 -0
  80. package/src/i18n/catalog.ts +113 -0
  81. package/src/i18n/locale.ts +153 -0
  82. package/src/i18n/localeMarker.ts +116 -0
  83. package/src/i18n/match.ts +111 -0
  84. package/src/i18n/registry.ts +78 -0
  85. package/src/i18n/translator.ts +168 -0
  86. package/src/index.ts +116 -0
  87. package/src/kv/kv.ts +437 -0
  88. package/src/kv/namespaces.ts +102 -0
  89. package/src/logger/local.ts +91 -0
  90. package/src/logger/logger.ts +145 -0
  91. package/src/logger/record.ts +83 -0
  92. package/src/logger/worker.ts +117 -0
  93. package/src/migrations/batch.ts +226 -0
  94. package/src/migrations/bookkeeping.ts +85 -0
  95. package/src/migrations/owner.ts +166 -0
  96. package/src/migrations/registry.ts +121 -0
  97. package/src/migrations/runner.ts +295 -0
  98. package/src/naming/domains.ts +194 -0
  99. package/src/naming/environment.ts +224 -0
  100. package/src/naming/feature.ts +162 -0
  101. package/src/naming/limits.ts +223 -0
  102. package/src/naming/provisionScope.ts +143 -0
  103. package/src/naming/resource.ts +266 -0
  104. package/src/naming/resourceNames.ts +174 -0
  105. package/src/naming/segment.ts +32 -0
  106. package/src/projection/asRead.ts +211 -0
  107. package/src/projection/published.ts +210 -0
  108. package/src/schema/describedness.ts +250 -0
  109. package/src/seed/compose.ts +94 -0
  110. package/src/seed/devLogin.ts +67 -0
  111. package/src/seed/exampleIdentities.ts +43 -0
  112. package/src/seed/metadata.ts +27 -0
  113. package/src/seed/seed.ts +306 -0
  114. package/src/seed/seededRows.ts +41 -0
  115. package/src/seed/writeD1.ts +103 -0
  116. package/src/seed/writeKv.ts +99 -0
  117. package/src/semver/semver.ts +156 -0
  118. package/src/text/comments.ts +165 -0
  119. package/src/version.generated.ts +16 -0
  120. package/src/worker/health.ts +42 -0
  121. package/src/worker/identity.ts +243 -0
  122. package/src/workflow/bindings.ts +58 -0
  123. package/src/workflow/dispatch.ts +240 -0
  124. package/src/workflow/dispatchRoute.ts +184 -0
  125. package/src/workflow/faults.ts +219 -0
  126. package/src/workflow/host.ts +307 -0
  127. package/src/workflow/hostEntry.ts +71 -0
  128. package/src/workflow/hostEnv.ts +258 -0
  129. package/src/workflow/loopback.ts +149 -0
  130. package/src/workflow/naming.ts +170 -0
  131. package/src/workflow/register.ts +44 -0
  132. package/src/workflow/schemas.ts +84 -0
  133. package/src/workflow/spec.ts +86 -0
  134. 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
+ }