@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,197 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database, D1PreparedStatement } from "@cloudflare/workers-types";
5
+ import { InternalError } from "../error/pithyError";
6
+
7
+ /**
8
+ * D1's bound-parameter ceiling, and the chunking every `in (...)` list needs to stay under it.
9
+ *
10
+ * D1 rejects a statement carrying more than 100 bound parameters. The failure mode is the nastiest kind:
11
+ * every small test passes, and the query only breaks once real data arrives. Worse, the naive fix —
12
+ * chunking the list at exactly 100 — is *also* wrong whenever the statement binds anything else. A
13
+ * `where("indexName", "=", name).where("id", "in", <100 ids>)` binds 101, and an `update … set(a, b)`
14
+ * ahead of the list binds two more before a single id is counted.
15
+ *
16
+ * So the unit here is not "the cap" but **the budget left after the statement's own fixed parameters**.
17
+ * A call site names how many parameters its statement binds besides the list, and the chunk size is
18
+ * derived. Adding a `where` to one of those queries is then a one-number edit next to it, not a silent
19
+ * re-break of a limit nobody re-derived.
20
+ *
21
+ * Lives in core rather than in each capability because the arithmetic is the part that was got wrong,
22
+ * and because `data/` is already where D1's quirks live (see `withD1Retry`).
23
+ */
24
+
25
+ /** D1's hard cap on bound parameters in one statement. Cloudflare documents it; over it is an error, not a truncation. */
26
+ export const MAX_BOUND_PARAMETERS = 100;
27
+
28
+ /**
29
+ * How many list values one statement may still bind once its fixed parameters are paid for.
30
+ *
31
+ * `fixed` is everything the statement binds that is *not* part of the chunked list — each `where` value,
32
+ * each `set` column, each limit. Throws when a statement leaves no room at all: that is a bug in the
33
+ * query, not a runtime condition, and silently returning zero would spin the caller's loop forever.
34
+ */
35
+ export function boundParameterBudget(fixed: number): number {
36
+ if (!Number.isInteger(fixed) || fixed < 0) {
37
+ throw new InternalError({
38
+ message: "Something unexpected happened.",
39
+ detail: `boundParameterBudget requires a non-negative integer count of fixed parameters; got ${fixed}.`,
40
+ });
41
+ }
42
+ const budget = MAX_BOUND_PARAMETERS - fixed;
43
+ if (budget < 1) {
44
+ throw new InternalError({
45
+ message: "Something unexpected happened.",
46
+ detail: `A statement binding ${fixed} fixed parameters leaves no room under D1's cap of ${MAX_BOUND_PARAMETERS}.`,
47
+ });
48
+ }
49
+ return budget;
50
+ }
51
+
52
+ /**
53
+ * Split `values` into chunks, each small enough that one statement binding `fixed` other parameters
54
+ * stays under D1's cap. An empty input yields no chunks, so a caller's loop simply does not run.
55
+ */
56
+ export function chunkByBoundParameters<T>(values: readonly T[], fixed: number): T[][] {
57
+ const size = boundParameterBudget(fixed);
58
+ const chunks: T[][] = [];
59
+ for (let start = 0; start < values.length; start += size) chunks.push(values.slice(start, start + size));
60
+ return chunks;
61
+ }
62
+
63
+ /**
64
+ * Split `rows` into chunks small enough that one multi-row `insert` stays under D1's cap.
65
+ *
66
+ * The sibling of {@link chunkByBoundParameters}, and a separate function because the arithmetic is
67
+ * genuinely different: an `in (…)` list binds **one** parameter per value, while an insert binds one
68
+ * per *column* per row. Reusing the list chunker for an insert silently allows `columns` times too
69
+ * many rows, which is a bug that passes every small test and only appears once real data arrives —
70
+ * `@pithy-sh/support` shipped exactly that, capping attachments at 10 rows of 11 columns and losing
71
+ * every one of them to `too many SQL variables`.
72
+ */
73
+ export function chunkRowsByBoundParameters<T>(rows: readonly T[], columnsPerRow: number, fixed = 0): T[][] {
74
+ if (!Number.isInteger(columnsPerRow) || columnsPerRow < 1) {
75
+ throw new InternalError({
76
+ message: "Something unexpected happened.",
77
+ detail: `chunkRowsByBoundParameters requires a positive column count; got ${columnsPerRow}.`,
78
+ });
79
+ }
80
+ const size = Math.floor(boundParameterBudget(fixed) / columnsPerRow);
81
+ if (size < 1) {
82
+ throw new InternalError({
83
+ message: "Something unexpected happened.",
84
+ detail: `A row of ${columnsPerRow} columns cannot fit under D1's cap of ${MAX_BOUND_PARAMETERS}.`,
85
+ });
86
+ }
87
+ const chunks: T[][] = [];
88
+ for (let start = 0; start < rows.length; start += size) chunks.push(rows.slice(start, start + size));
89
+ return chunks;
90
+ }
91
+
92
+ /** Read a property off a platform object without losing its `this` — workerd's D1 objects need it. */
93
+ function passThrough(target: object, property: PropertyKey): unknown {
94
+ const value = Reflect.get(target, property);
95
+ return typeof value === "function" ? value.bind(target) : value;
96
+ }
97
+
98
+ /** Enough of a statement to name it in a failure, without carrying a query of unbounded length. */
99
+ function nameOf(query: string): string {
100
+ const collapsed = query.replace(/\s+/g, " ").trim();
101
+ return collapsed.length > 160 ? `${collapsed.slice(0, 157)}…` : collapsed;
102
+ }
103
+
104
+ /**
105
+ * D1, wrapped so a statement over the cap fails as **the rule** rather than as SQLite's complaint.
106
+ *
107
+ * `createDatabase` applies this to every database it builds, which is the whole point: the rule lives at
108
+ * the thing being called, not at each of the query sites that call it. A capability written next month
109
+ * that has never heard of the cap is covered by it, and so is one written last year — which matters,
110
+ * because the list of sites that got this wrong has been wrong five times running. Four capabilities
111
+ * bound past the cap while the arithmetic to avoid it sat in this file, unimported (#250).
112
+ *
113
+ * The check is at `bind`, because that is the only place the number is real: it is what the driver hands
114
+ * the platform, after Kysely has compiled the statement and after any chunking a caller did. Anything
115
+ * measured earlier measures intent.
116
+ *
117
+ * It does not chunk. It cannot — splitting an arbitrary statement is the caller's arithmetic, and
118
+ * {@link chunkByBoundParameters} and {@link chunkRowsByBoundParameters} are where it belongs. What this
119
+ * buys is that the failure names the rule that was broken, one statement before the platform says
120
+ * `too many SQL variables` and leaves the author to work out which list was too long.
121
+ */
122
+ export function guardBoundParameters(d1: D1Database): D1Database {
123
+ return new Proxy(d1, {
124
+ get(target, property) {
125
+ if (property !== "prepare") return passThrough(target, property);
126
+ return (query: string): D1PreparedStatement => {
127
+ const statement = target.prepare(query);
128
+ return new Proxy(statement, {
129
+ get(inner, key) {
130
+ if (key !== "bind") return passThrough(inner, key);
131
+ // Returns the *real* bound statement, not another proxy: a bound statement is handed
132
+ // straight to `D1Database.batch`, which is a platform call that will not take a wrapper.
133
+ return (...values: unknown[]): D1PreparedStatement => {
134
+ if (values.length > MAX_BOUND_PARAMETERS) {
135
+ throw new InternalError({
136
+ message: "Something unexpected happened.",
137
+ action: "Retry. If it persists, the query needs chunking.",
138
+ detail: `A statement bound ${values.length} parameters; D1 accepts ${MAX_BOUND_PARAMETERS}. Chunk the list through chunkByBoundParameters (one parameter per value) or chunkRowsByBoundParameters (one per column per row). Statement: ${nameOf(query)}`,
139
+ });
140
+ }
141
+ return inner.bind(...values);
142
+ };
143
+ },
144
+ });
145
+ };
146
+ },
147
+ });
148
+ }
149
+
150
+ /** What one recorded run bound, and whatever it threw. */
151
+ export interface BoundParameterRecording {
152
+ /** Every parameter count D1 was asked to bind, in the order the statements ran. */
153
+ counts: number[];
154
+ /** What `run` threw, or `undefined`. Returned rather than propagated — see below. */
155
+ error: unknown;
156
+ }
157
+
158
+ /**
159
+ * Run `run` against a recording D1 and report every parameter count it bound.
160
+ *
161
+ * A gate wants to assert the ceiling *before* it rethrows, so the failure an author reads is the
162
+ * invariant rather than whatever the platform said about it. Hence the throw comes back in the result
163
+ * instead of propagating: the caller asserts the counts, then rethrows anything left.
164
+ *
165
+ * The counterpart to {@link guardBoundParameters} rather than a duplicate of it. The guard proves a
166
+ * statement was *refused*; this proves what was *bound* — that chunks fill the budget rather than
167
+ * creeping under it, and that chunking lost no rows on the way.
168
+ */
169
+ export async function recordBoundParameters(
170
+ d1: D1Database,
171
+ run: (d1: D1Database) => Promise<void>,
172
+ ): Promise<BoundParameterRecording> {
173
+ const counts: number[] = [];
174
+ const recorder = new Proxy(d1, {
175
+ get(target, property) {
176
+ if (property !== "prepare") return passThrough(target, property);
177
+ return (query: string): D1PreparedStatement => {
178
+ const statement = target.prepare(query);
179
+ return new Proxy(statement, {
180
+ get(inner, key) {
181
+ if (key !== "bind") return passThrough(inner, key);
182
+ return (...values: unknown[]): D1PreparedStatement => {
183
+ counts.push(values.length);
184
+ return inner.bind(...values);
185
+ };
186
+ },
187
+ });
188
+ };
189
+ },
190
+ });
191
+ try {
192
+ await run(recorder);
193
+ return { counts, error: undefined };
194
+ } catch (error) {
195
+ return { counts, error };
196
+ }
197
+ }
@@ -0,0 +1,160 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * SQLite/D1 codecs: bidirectional JS ↔ SQLite conversion through Zod.
8
+ *
9
+ * SQLite has no booleans, no real dates, and no JSON — it stores 0|1, ms-epoch
10
+ * numbers, and strings. These codecs make that conversion the schema's job.
11
+ * `.parse()` decodes (DB → app); `.encode()` encodes (app → DB). Decode-side
12
+ * input is always a `z.union` (never `z.preprocess`) so the schema stays
13
+ * encode-compatible.
14
+ *
15
+ * **A codec reports; it never throws.** `safeParse` cannot throw — only `parse`
16
+ * throws — and every boundary reader in this kit and in the dashboard is written
17
+ * on that promise: `const parsed = X.safeParse(body); return parsed.success ?
18
+ * parsed.data : null;`, documented as *never throws*. Zod's `safeParse` catches a
19
+ * `ZodError`; it does **not** catch an arbitrary exception raised inside a
20
+ * transform, which propagates straight past it and out of the reader. So a
21
+ * transform that meets a value it cannot convert pushes a `$ZodRawIssue` onto the
22
+ * payload and returns `z.NEVER`. Zod aborts there, the failure arrives as
23
+ * `{ success: false }`, and `parse` still throws — a `ZodError`, which
24
+ * `fromZodError` maps to `validation/invalid_input` like every other failed parse.
25
+ *
26
+ * `JsonDate` and `SQLiteDate` threw an `InternalError` from inside `decode`, so a
27
+ * malformed timestamp from a customer's Worker was a 500 rather than a rejected
28
+ * field, on every pane that read a date (#358). The rule lives here rather than at
29
+ * the readers because a `try`/`catch` at a call site is the same defect one frame
30
+ * further out, and the next codec would not inherit it.
31
+ *
32
+ * The offending value rides along as the issue's `input`, which is where Zod puts
33
+ * it and which {@link fromZodError} drops — it keeps `path`, `message` and `code`
34
+ * only, so the value stays available to a logger at the throw site and never
35
+ * reaches a client. The `message` is written to be read by one: client-safe, and
36
+ * still specific enough to say *what* was wrong.
37
+ */
38
+
39
+ /**
40
+ * The invariant every transform below is written to, stated once so it can be cited:
41
+ * push, return `z.NEVER`, never `throw`.
42
+ */
43
+ type Payload<T> = z.core.ParsePayload<T>;
44
+
45
+ const TRUTHY: readonly unknown[] = [true, 1, "1", "true", "True", "TRUE"];
46
+
47
+ function isTruthy(value: unknown): boolean {
48
+ return TRUTHY.includes(value);
49
+ }
50
+
51
+ /** SQLiteBoolean: boolean ↔ 0|1. Decode is lenient (number/boolean/string). */
52
+ export const SQLiteBoolean = z.codec(z.union([z.literal(0), z.literal(1), z.boolean(), z.string()]), z.boolean(), {
53
+ decode: (input: 0 | 1 | boolean | string): boolean => (typeof input === "boolean" ? input : isTruthy(input)),
54
+ encode: (value: boolean): 0 | 1 => (value ? 1 : 0),
55
+ });
56
+
57
+ /**
58
+ * The shared decode for both date codecs. A string is parsed, a number is ms-epoch — what `encode`
59
+ * writes, so a caller ingesting seconds-epoch from elsewhere converts explicitly — and a `Date` passes
60
+ * through by identity.
61
+ *
62
+ * **Every branch can produce an unusable `Date`**, not only the string one: `new Date(8.64e15 + 1)` is
63
+ * as invalid as `new Date("not-a-date")`. So the check is on the result, once, rather than on the input
64
+ * shape — which is what stops the next accepted input type from arriving without it.
65
+ */
66
+ function toDate(input: number | string | Date, payload: Payload<number | string | Date>): Date {
67
+ const parsed = input instanceof Date ? input : new Date(input);
68
+ if (Number.isNaN(parsed.getTime())) {
69
+ payload.issues.push({ code: "invalid_format", format: "datetime", input: String(input), message: "Not a date." });
70
+ return z.NEVER;
71
+ }
72
+ return parsed;
73
+ }
74
+
75
+ /** SQLiteDate: Date ↔ ms-epoch number. Decode accepts number/string/Date. */
76
+ export const SQLiteDate = z.codec(z.union([z.number(), z.string(), z.date()]), z.date(), {
77
+ decode: toDate,
78
+ encode: (value: Date): number => value.getTime(),
79
+ });
80
+
81
+ /** JsonDate: Date ↔ ISO string, for dates nested inside `sqliteJson` payloads. */
82
+ export const JsonDate = z.codec(z.union([z.string(), z.number(), z.date()]), z.date(), {
83
+ decode: toDate,
84
+ encode: (value: Date): string => value.toISOString(),
85
+ });
86
+
87
+ /**
88
+ * sqliteJson: T ↔ JSON string, validated by `schema` on both sides. Decode
89
+ * accepts a JSON string or an already-parsed value; the result is validated
90
+ * against `schema` before it reaches the app.
91
+ *
92
+ * **Both `JSON` functions throw**, and both were reachable. `JSON.parse` throws a
93
+ * `SyntaxError` on any string that is not a JSON document — which a column holding
94
+ * text an older writer put there, or a payload a customer's Worker returned, can be.
95
+ * `JSON.stringify` throws a `TypeError` on a `BigInt` and on a cycle, either of
96
+ * which a `schema` can legitimately admit. Both become an issue (#358).
97
+ */
98
+ export function sqliteJson<T extends z.ZodType>(schema: T) {
99
+ return z.codec(z.union([z.string(), schema]), schema, {
100
+ decode: (value, payload): z.input<T> => {
101
+ if (typeof value !== "string") return value as z.input<T>;
102
+ try {
103
+ return JSON.parse(value) as z.input<T>;
104
+ } catch {
105
+ payload.issues.push({ code: "invalid_format", format: "json", input: value, message: "Not valid JSON." });
106
+ return z.NEVER;
107
+ }
108
+ },
109
+ encode: (value, payload): string => {
110
+ // The offending value is deliberately not carried onto the issue: it is the thing JSON could not
111
+ // hold, so a reporter that renders an issue would be the second place to trip over it.
112
+ try {
113
+ const json = JSON.stringify(value);
114
+ if (typeof json === "string") return json;
115
+ } catch {
116
+ // Falls through to the one push below. A `BigInt`, a cycle and an `undefined` result are the
117
+ // same answer — this value does not survive the round trip — and they deserve the same issue.
118
+ }
119
+ payload.issues.push({
120
+ code: "invalid_format",
121
+ format: "json",
122
+ input: undefined,
123
+ message: "Not serializable as JSON.",
124
+ });
125
+ return z.NEVER;
126
+ },
127
+ });
128
+ }
129
+
130
+ export const MAX_TIMEZONE_LENGTH = 64;
131
+
132
+ /**
133
+ * Trim, validate, and canonicalize an IANA timezone; anything invalid becomes
134
+ * `undefined`. Returns Intl's canonical casing (`america/new_york` →
135
+ * `America/New_York`) so a zone is stored and compared as one value.
136
+ */
137
+ export function normalizeIanaTimezone(value: unknown): string | undefined {
138
+ if (typeof value !== "string") return undefined;
139
+ const trimmed = value.trim();
140
+ if (trimmed.length === 0 || trimmed.length > MAX_TIMEZONE_LENGTH) return undefined;
141
+ try {
142
+ return Intl.DateTimeFormat(undefined, { timeZone: trimmed }).resolvedOptions().timeZone;
143
+ } catch {
144
+ return undefined;
145
+ }
146
+ }
147
+
148
+ /** Whether `value` is a usable IANA timezone (bounded length, recognized by Intl). */
149
+ export function isValidIanaTimezone(value: string): boolean {
150
+ return normalizeIanaTimezone(value) !== undefined;
151
+ }
152
+
153
+ /**
154
+ * IanaTimezone: validated/bounded IANA timezone string. Coerces invalid input
155
+ * to `undefined` on both decode and encode rather than throwing.
156
+ */
157
+ export const IanaTimezone = z.codec(z.string().nullish(), z.string().optional(), {
158
+ decode: (input: string | null | undefined): string | undefined => normalizeIanaTimezone(input),
159
+ encode: (value: string | undefined): string | undefined => normalizeIanaTimezone(value),
160
+ });
@@ -0,0 +1,127 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * Keyset pagination, once, for every capability that lists an unbounded table.
8
+ *
9
+ * ## Why keyset and never offset
10
+ *
11
+ * `OFFSET` shifts under a client whenever a row is inserted, and every table these paginate — audit
12
+ * events, email jobs, users, ledger transactions — is written to constantly while somebody is reading
13
+ * it. With `OFFSET 25` a row inserted at the head pushes one row from page 1 onto page 2, so a client
14
+ * paging through sees it twice and misses nothing only by luck; a deletion drops a row entirely. A
15
+ * keyset cursor names the last row's sort position, so the next page starts exactly where the previous
16
+ * ended whatever happened in between.
17
+ *
18
+ * ## Why this is in core rather than copied
19
+ *
20
+ * `@pithy-sh/support` had the only implementation, and Part 3 of #89 needs the same thing in four more
21
+ * packages. Four copies of a security-adjacent decode — where the difference between "malformed cursor"
22
+ * and "500" is a caller's ability to probe — is four places for one of them to be wrong. Capabilities
23
+ * depend on core seams, which is exactly what this is.
24
+ *
25
+ * ## The shape of a page
26
+ *
27
+ * A caller asks for `limit`; the query fetches `limit + 1` and, when it gets them, drops the extra and
28
+ * returns a `nextCursor`. That is what makes "is there another page" answerable **without a count
29
+ * query**, which on an audit table is the difference between a page load and a table scan.
30
+ */
31
+
32
+ /** A sensible page when a caller names none. Small enough to render, large enough to be one request. */
33
+ export const DEFAULT_PAGE_SIZE = 25;
34
+
35
+ /** The most a caller may ask for in one page, whatever they send. An unbounded page is a table scan. */
36
+ export const MAX_PAGE_SIZE = 100;
37
+
38
+ /**
39
+ * The `limit` a query should actually use, clamped into range.
40
+ *
41
+ * Clamped rather than rejected: a client asking for 1000 wants "as many as you'll give me", and failing
42
+ * the request teaches it nothing a capped page does not. A caller asking for 0 or a negative gets one
43
+ * row rather than an empty page that looks like the end of the list.
44
+ */
45
+ export function pageLimit(requested: number | undefined): number {
46
+ return Math.min(Math.max(requested ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
47
+ }
48
+
49
+ /**
50
+ * An opaque position in a descending `(sort, id)` ordering.
51
+ *
52
+ * `sort` is the primary column's value — a ms-epoch for a date, a number for a monotonic id. `id` is the
53
+ * tiebreak that makes the position exact: without it, two rows sharing a timestamp straddle a page
54
+ * boundary and one of them is skipped or repeated.
55
+ */
56
+ export const PageCursor = z
57
+ .object({
58
+ sort: z
59
+ .union([z.number(), z.string()])
60
+ .describe(
61
+ "The sort column's value on the last row of the previous page — a ms-epoch for a date column, a number for a monotonic id, a string for an ISO-8601 date column. A string and a number compare differently in SQLite, so this carries whichever the column actually stores.",
62
+ ),
63
+ id: z
64
+ .union([z.number(), z.string()])
65
+ .describe(
66
+ "That row's primary key, the tiebreak that makes the position exact. Two rows sharing a sort value would otherwise straddle a page boundary, and one of them would be skipped or returned twice.",
67
+ ),
68
+ })
69
+ .describe("An opaque position in a descending (sort, id) ordering — where the next page starts.");
70
+ export type PageCursor = z.infer<typeof PageCursor>;
71
+
72
+ /**
73
+ * Encode a cursor for the wire.
74
+ *
75
+ * Base64url over JSON, and **opaque by intent rather than by obscurity**: the point is that a client
76
+ * cannot construct one by hand and therefore cannot come to depend on its shape, so the ordering can
77
+ * change later without breaking every caller. It is not a secret — it holds a sort value and an id the
78
+ * caller was just given, both of which were in the page it came from.
79
+ */
80
+ export function encodeCursor(cursor: PageCursor): string {
81
+ // UTF-8 first. `btoa` throws `InvalidCharacterError` on any code unit above U+00FF, and a cursor's
82
+ // `id` can be a caller-minted string — a device id, an external reference — so a single non-Latin-1
83
+ // character would turn a page boundary into a 500 rather than a cursor.
84
+ const bytes = new TextEncoder().encode(JSON.stringify(cursor));
85
+ let binary = "";
86
+ for (const byte of bytes) binary += String.fromCharCode(byte);
87
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
88
+ }
89
+
90
+ /**
91
+ * Decode a cursor. Returns undefined for anything malformed.
92
+ *
93
+ * **A bad cursor is a first page, not a 500.** Cursors travel through URLs, get truncated by clients,
94
+ * and outlive deploys; treating a malformed one as an error turns an ordinary client bug into a page
95
+ * that never loads, and hands anyone probing the endpoint a way to tell a parse failure from a
96
+ * not-found. Every failure mode here — bad base64, bad JSON, wrong shape — collapses to the same
97
+ * undefined.
98
+ */
99
+ export function decodeCursor(value: string | undefined): PageCursor | undefined {
100
+ if (!value) return undefined;
101
+ try {
102
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
103
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
104
+ const parsed = PageCursor.safeParse(JSON.parse(new TextDecoder().decode(bytes)));
105
+ return parsed.success ? parsed.data : undefined;
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Split an over-fetched result into the page and the cursor that follows it.
113
+ *
114
+ * Pass the rows a query returned when asked for `limit + 1`. When there are more than `limit`, the extra
115
+ * is dropped and `nextCursor` names the last row of the page; otherwise `nextCursor` is null and the
116
+ * client knows it has reached the end without a second request.
117
+ */
118
+ export function toPage<T>(
119
+ rows: readonly T[],
120
+ limit: number,
121
+ position: (row: T) => PageCursor,
122
+ ): { items: T[]; nextCursor: string | null } {
123
+ const hasMore = rows.length > limit;
124
+ const items = hasMore ? rows.slice(0, limit) : [...rows];
125
+ const last = items[items.length - 1];
126
+ return { items, nextCursor: hasMore && last !== undefined ? encodeCursor(position(last)) : null };
127
+ }
@@ -0,0 +1,84 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { Kysely } from "kysely";
6
+ import type { Migration } from "kysely/migration";
7
+ import type { z } from "zod";
8
+ import type { Capability } from "../capability/capability";
9
+ import { type BindingGroup, bindingGroupsFrom, composeBindingGroups } from "../capability/compose";
10
+ import { createDatabase, type DatabaseSchema, type SchemaMap } from "./db";
11
+
12
+ /**
13
+ * One named D1 database a capability contributes tables to: the `binding` it lives in plus this
14
+ * capability's slice of its schema. A production Worker commonly binds several D1 databases (app
15
+ * data, analytics, …); each is a named database here, and `createBackend` serves one typed `Kysely`
16
+ * per name on `c.var.db.<name>`. Capabilities targeting the same database name merge their slices.
17
+ */
18
+ export interface DatabaseSpec<Tables extends SchemaMap = SchemaMap> {
19
+ /** The D1 binding name in the Worker env this database lives in. */
20
+ binding: string;
21
+ /** This capability's slice of the database's schema (table name → Zod table schema). */
22
+ tables: Tables;
23
+ /**
24
+ * This capability's migrations for this database, by stable local key (e.g. "0001_init") —
25
+ * co-located with the tables they create, so the database association is declared once. The
26
+ * migration namespace is the capability's name; `pithy migrate` composes every capability's
27
+ * sets through `createMigrationRegistry` and runs them per database.
28
+ */
29
+ migrations?: Record<string, Migration>;
30
+ /** Sort order within this database relative to other capabilities (core low, app high). Required with `migrations`. */
31
+ migrationOrder?: number;
32
+ }
33
+
34
+ /** A capability's databases: database name → {@link DatabaseSpec}. */
35
+ export type DatabaseSpecMap = Record<string, DatabaseSpec>;
36
+
37
+ /**
38
+ * The typed database registry exposed on `c.var.db`: each named database becomes its live `Kysely`
39
+ * over that database's merged schema — so `c.var.db.app.selectFrom("…")` and
40
+ * `c.var.db.analytics.selectFrom("…")` are each typed to their own tables.
41
+ */
42
+ export type DbRegistry<Dbs extends DatabaseSpecMap> = {
43
+ [Name in keyof Dbs]: Dbs[Name] extends DatabaseSpec<infer Tables> ? Kysely<DatabaseSchema<Tables>> : never;
44
+ };
45
+
46
+ /** The merged databases: each database name → its binding and the union of every capability's tables. */
47
+ export type MergedDatabases = Record<string, BindingGroup<z.ZodType>>;
48
+
49
+ /**
50
+ * Merge every capability's `databases` into one map keyed by database name. Capabilities targeting
51
+ * the same name union their table slices (the project-wide schema, per database); a table claimed
52
+ * twice in one database, or a database name bound to two bindings, throws at assembly.
53
+ */
54
+ export function composeDatabases(capabilities: Capability[]): MergedDatabases {
55
+ return composeBindingGroups<z.ZodType>(
56
+ capabilities,
57
+ (cap) => bindingGroupsFrom(cap.databases, (db) => db.tables),
58
+ "database",
59
+ "table",
60
+ );
61
+ }
62
+
63
+ /** A registry of live Kysely instances keyed by database name; the per-request value of `c.var.db`. */
64
+ type LiveDbRegistry = Record<string, Kysely<DatabaseSchema<SchemaMap>>>;
65
+
66
+ /**
67
+ * Build the per-request database registry from the merged databases and the request's env. Each
68
+ * `Kysely` is constructed lazily on first access from its D1 binding — a request that never touches
69
+ * `c.var.db.analytics` never builds it.
70
+ */
71
+ export function buildDbRegistry(env: Record<string, unknown>, databases: MergedDatabases): LiveDbRegistry {
72
+ const registry: LiveDbRegistry = {};
73
+ for (const [name, group] of Object.entries(databases)) {
74
+ let instance: Kysely<DatabaseSchema<SchemaMap>> | undefined;
75
+ Object.defineProperty(registry, name, {
76
+ enumerable: true,
77
+ get(): Kysely<DatabaseSchema<SchemaMap>> {
78
+ if (!instance) instance = createDatabase(env[group.binding] as D1Database, group.items);
79
+ return instance;
80
+ },
81
+ });
82
+ }
83
+ return registry;
84
+ }
package/src/data/db.ts ADDED
@@ -0,0 +1,53 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { CamelCasePlugin, type Generated, Kysely } from "kysely";
6
+ import { D1Dialect } from "kysely-d1";
7
+ import type { z } from "zod";
8
+ import { guardBoundParameters } from "./boundParameters";
9
+
10
+ /**
11
+ * The Kysely D1 database builder.
12
+ *
13
+ * One master map of table name → Zod table schema is the whole database
14
+ * definition. The database interface Kysely sees is derived from each schema's
15
+ * `z.input` side — the SQLite row shape — never a hand-written row interface.
16
+ * `CamelCasePlugin` is mandatory: tables use snake_case columns, query code
17
+ * uses camelCase, and the plugin bridges the two in both directions.
18
+ */
19
+
20
+ /** A master map of table name → that table's Zod schema. */
21
+ export type SchemaMap = Record<string, z.ZodType>;
22
+
23
+ /**
24
+ * Present a numeric `id` to Kysely as `Generated<number>`: optional on insert
25
+ * (D1 assigns it), a number on select. A table without a numeric `id` is left
26
+ * unchanged. Externally-exposed entities use a text id and so are untouched.
27
+ */
28
+ export type WithGeneratedId<Row> = Row extends { id: number } ? Omit<Row, "id"> & { id: Generated<number> } : Row;
29
+
30
+ /** The Kysely database interface derived from a schema map's `z.input` (row) side. */
31
+ export type DatabaseSchema<M extends SchemaMap> = {
32
+ [K in keyof M]: WithGeneratedId<z.input<M[K]>>;
33
+ };
34
+
35
+ /**
36
+ * Build a typed Kysely instance over D1 for `map`. The `map` argument carries
37
+ * the table types — it drives inference of the database interface; Kysely needs
38
+ * no values from it at runtime. `CamelCasePlugin` is always installed.
39
+ *
40
+ * So is `guardBoundParameters`. This is the one seam every Kysely instance in
41
+ * the repository comes from, so it is the one place the bound-parameter ceiling
42
+ * can be stated for all of them — including for query sites that have never
43
+ * heard of it. Five capabilities bound past the cap while the arithmetic to
44
+ * avoid it sat unimported in `boundParameters.ts` (#246, #250); a rule that
45
+ * every call site has to remember is a rule in the wrong place.
46
+ */
47
+ export function createDatabase<M extends SchemaMap>(d1: D1Database, map: M): Kysely<DatabaseSchema<M>> {
48
+ void map; // type-only carrier; see doc comment
49
+ return new Kysely<DatabaseSchema<M>>({
50
+ dialect: new D1Dialect({ database: guardBoundParameters(d1) }),
51
+ plugins: [new CamelCasePlugin()],
52
+ });
53
+ }