@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,306 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+ import type { KvStoreSpec } from "../kv/namespaces";
6
+ import type { SeededRows } from "./seededRows";
7
+
8
+ /**
9
+ * The seed contract: the pure, schema-bound shapes a capability (or the app) contributes to
10
+ * `pithy seed`. A capability declares its fixtures as {@link SeedSet}s on `Capability.seeds`;
11
+ * `composeSeeds` merges every capability's sets library-before-app, and the CLI writes them,
12
+ * idempotently and non-destructively, into the environment named by `--env`.
13
+ *
14
+ * Rows and entries are authored as **app-shape values** (the decoded, `z.output` side). The
15
+ * write-time `schema.encode(row)` in `seedD1Group` is the single Zod validation boundary — a bad
16
+ * fixture fails there, before any write. Core defines only the types and the pure write/compose
17
+ * logic; filesystem work (media bytes, UUID write-back) lives in the CLI.
18
+ */
19
+
20
+ /**
21
+ * An environment name a set may be seeded into (`dev`, `staging`, `production`, or any custom
22
+ * environment). A set lists the environments it is allowed into on {@link SeedSet.environments};
23
+ * `production` is only ever seeded when a set lists it explicitly.
24
+ */
25
+ export type SeedEnv = string;
26
+
27
+ /**
28
+ * One D1 seed group: a batch of app-shape rows for a single table in a single named database.
29
+ * `database` and `table` match the names a capability declares in its `databases` map; the row
30
+ * schema is resolved from the composed databases registry at write time (the fixture never
31
+ * redeclares it). `rows` are `z.output` (app shape) values — re-encoded and validated on write.
32
+ */
33
+ export interface D1SeedGroup<Table extends z.ZodType = z.ZodType> {
34
+ /** The named database this table lives in (matches a `databases` key, e.g. "app", "analytics"). */
35
+ database: string;
36
+ /** The table name to seed (the un-prefixed logical name used in query code). */
37
+ table: string;
38
+ /** The rows to insert, as app-shape (`z.output`) values — encoded + validated at write time. */
39
+ rows: readonly z.output<Table>[];
40
+ }
41
+
42
+ /**
43
+ * One KV seed entry: an app-shape key/value (and optional metadata) for a store. The values are
44
+ * the store's input shapes; {@link TypedKv.put} validates each on write.
45
+ */
46
+ export interface KvSeedEntry<
47
+ V extends z.ZodType = z.ZodType,
48
+ K extends z.ZodObject = z.ZodObject,
49
+ M extends z.ZodType = z.ZodType,
50
+ > {
51
+ /** The structured key segments, validated by the store's key schema. */
52
+ key: z.input<K>;
53
+ /** The value to store, validated by the store's value schema. */
54
+ value: z.input<V>;
55
+ /** Optional metadata, validated by the store's metadata schema. */
56
+ metadata?: z.input<M>;
57
+ }
58
+
59
+ /**
60
+ * One KV seed group: a batch of entries for a single named store within a named namespace.
61
+ * `namespace` and `store` match the names a capability declares in its `kvNamespaces` map.
62
+ */
63
+ export interface KvSeedGroup<
64
+ V extends z.ZodType = z.ZodType,
65
+ K extends z.ZodObject = z.ZodObject,
66
+ M extends z.ZodType = z.ZodType,
67
+ > {
68
+ /** The named KV namespace this store lives in (matches a `kvNamespaces` key). */
69
+ namespace: string;
70
+ /** The store name within the namespace to seed. */
71
+ store: string;
72
+ /** The entries to write — each validated by the store on put. */
73
+ entries: readonly KvSeedEntry<V, K, M>[];
74
+ }
75
+
76
+ /**
77
+ * One R2 object to seed: raw bytes (or a string) under a key in a bound bucket. R2 has no schema
78
+ * layer, so the payload is opaque; the CLI writes it via the bucket manager for the target env.
79
+ */
80
+ export interface R2SeedItem {
81
+ /** The R2 binding name in the Worker env this object lives in. */
82
+ binding: string;
83
+ /** The object key. */
84
+ key: string;
85
+ /** The object bytes, or a string body. */
86
+ body: Uint8Array | string;
87
+ /** The object's content type (e.g. "image/png"). */
88
+ contentType: string;
89
+ /** Optional custom metadata stored alongside the object. */
90
+ metadata?: Record<string, string>;
91
+ }
92
+
93
+ /**
94
+ * One media asset to seed into the shared Cloudflare Images or Stream store. Core defines only the
95
+ * type — the filesystem read (`file`), the upload, and the UUID write-back (`ref`) run in the CLI.
96
+ *
97
+ * `once` (the default) uploads on the first run and records the minted UUID in the `ref` sidecar,
98
+ * so later runs skip the upload and reference the recorded id. `always` re-uploads every run,
99
+ * scoped by metadata, and never writes a UUID back.
100
+ */
101
+ export interface MediaSeedItem {
102
+ /** Which shared asset store to upload to. */
103
+ store: "images" | "stream";
104
+ /** Upload policy: `once` (upload + record the UUID) or `always` (re-upload each run). */
105
+ mode: "once" | "always";
106
+ /**
107
+ * Path to the asset bytes. Resolved against the set's {@link SeedSet.baseDir} (the seed module's own
108
+ * directory, set with `import.meta.dirname`), or the project root when the set declares none. Read by
109
+ * the CLI. An absolute path is used as-is.
110
+ */
111
+ file: string;
112
+ /**
113
+ * Path to the JSON sidecar that records the minted UUID (for `once`). Resolved the same way as
114
+ * {@link MediaSeedItem.file} — against the set's {@link SeedSet.baseDir} or the project root.
115
+ * Read/written by the CLI. An absolute path is used as-is.
116
+ */
117
+ ref: string;
118
+ /** Optional custom metadata merged with the standard asset metadata block on upload. */
119
+ metadata?: Record<string, string>;
120
+ /**
121
+ * Optional D1 asset row (in `database`.`table`) that references the minted UUID. Only valid with
122
+ * `mode: "once"`: the row is keyed by the stable, recorded UUID, so an `always` asset — which mints
123
+ * a fresh UUID every run — cannot own a stable record. The CLI rejects `always` + `record`.
124
+ */
125
+ record?: {
126
+ /** The named database the asset row lives in. */
127
+ database: string;
128
+ /** The table the asset row is written to. */
129
+ table: string;
130
+ /** The app-shape row — the CLI fills in the minted UUID before writing it. */
131
+ row: unknown;
132
+ };
133
+ }
134
+
135
+ /**
136
+ * A transient file a prepared set asks the CLI to write, under the project's gitignored `logs/`.
137
+ *
138
+ * The path is not the author's choice: a set names the file, never the directory, so a fixture can never
139
+ * put a run artifact somewhere committable. The one artifact that exists today is the dev login
140
+ * (`DEV_LOGIN_PATH`), which holds a live session cookie — hence both halves of that rule.
141
+ */
142
+ export interface SeedArtifact {
143
+ /** The file name within the project's `logs/` directory (no directory separators). */
144
+ file: string;
145
+ /** The file's contents, written verbatim after the set's rows land. */
146
+ contents: string;
147
+ }
148
+
149
+ /**
150
+ * What a run gives {@link SeedSet.prepare} so it can compute the fixtures it cannot state statically.
151
+ *
152
+ * Deliberately narrow. `secret` is the only I/O the set performs, and it is the reason the hook exists: a
153
+ * fixture that must
154
+ * agree with what the running app will verify — a signed cookie, a derived id — needs the same secret the
155
+ * app reads, and a static row cannot hold one. Everything else is passed in already-read, so a capability
156
+ * never touches the filesystem: a capability module is bundled into the Worker, and `node:fs` there is a
157
+ * build error rather than a possibility. For the same reason `seeded` is the composed plan rather than a
158
+ * database handle — a prepared set answers "does this row exist" from the run, never from a query.
159
+ */
160
+ export interface SeedPrepareContext {
161
+ /** The environment being seeded — the same value the set's `environments` allowed. */
162
+ env: string;
163
+ /** The project name (the root `pithy.config.ts` `name`), for messages and per-project lookups. */
164
+ project: string;
165
+ /**
166
+ * Where this Worker answers locally — `http://localhost:8807` and the like. `null` outside `dev`, and
167
+ * `null` in a checkout that was never allocated a port block.
168
+ *
169
+ * The one address a fixture cannot write down. A dev port is *allocated*, not configured: a checkout
170
+ * reserves a block and pins one port per Worker into `.dev.config.json`, so `http://localhost:8787` is
171
+ * right in the first checkout on a machine and wrong in every other one. A set that has to register a
172
+ * self-connection had nothing to ask, so it hard-coded that literal and every second checkout addressed
173
+ * the first one's Worker. This is the string the run was actually allocated — the same one `pithy dev`
174
+ * exports to this Worker's siblings as `<STEM>_ORIGIN`, read back verbatim and never recomposed from
175
+ * the port.
176
+ *
177
+ * **An address, not an identity.** It says where to reach this Worker, on this machine, now. Anything a
178
+ * later check compares against a stored value — an issuer, an audience, a signing scope — must not be
179
+ * built from it: the same project answers on a different port in every checkout and a different origin
180
+ * in every environment, so a row minted against one is unverifiable against the next. Reachability
181
+ * moves. Identity must not.
182
+ *
183
+ * **`null` is an answer, never a guess.** A plain clone that has never run `pithy dev`, a Worker added
184
+ * after the block was pinned, or any environment but `dev` — a deployed environment's address is
185
+ * declared rather than allocated, and `pithy env` is what answers it. An invented origin would be
186
+ * indistinguishable from a real one, so a set that cannot work without one refuses and says so.
187
+ */
188
+ origin: string | null;
189
+ /**
190
+ * Read one of this environment's secrets by name, or `undefined` when it is not set. Local dev resolves
191
+ * every secret from `.dev.vars`, so this answers there; a deployed environment's secrets are not on the
192
+ * operator's disk, and a set that needs one must therefore be `dev`-only.
193
+ */
194
+ secret: (name: string) => Promise<string | undefined>;
195
+ /**
196
+ * The developer's machine-local preferences for this project, read from the Pithy config directory
197
+ * (`~/.config/pithy/<project>/dev.json`, or `%APPDATA%\pithy\<project>\dev.json` on Windows), parsed but
198
+ * unvalidated — `undefined` when the file is absent or unreadable. It is a per-machine opt-in, outside
199
+ * the repo, so two developers on one checkout can differ and neither has to commit anything. The set
200
+ * validates the shape it expects.
201
+ */
202
+ preferences: unknown;
203
+ /**
204
+ * The rows this run declares for one `database`.`table`, from every composed set — this one included, and
205
+ * every other capability's and the app's. It is how a fixture that must reference a row it does not own
206
+ * (a session for a seeded user) finds that row without hard-coding a roster or opening D1. Statically
207
+ * declared rows only; see {@link SeededRows}.
208
+ */
209
+ seeded: SeededRows;
210
+ }
211
+
212
+ /**
213
+ * What {@link SeedSet.prepare} contributes: more of the same groups a static set declares, plus any
214
+ * transient artifacts. The groups are appended to the set's static ones and written through the identical
215
+ * validated path; the artifacts are written afterwards, so a file never claims a row that failed to land.
216
+ */
217
+ export interface SeedPreparation {
218
+ /** D1 row groups computed for this run. */
219
+ d1?: readonly D1SeedGroup[];
220
+ /** KV entry groups computed for this run. */
221
+ kv?: readonly KvSeedGroup[];
222
+ /** Transient files to write under the project's `logs/` once the rows are in. */
223
+ artifacts?: readonly SeedArtifact[];
224
+ }
225
+
226
+ /**
227
+ * One named, ordered batch of fixtures a capability contributes to `pithy seed`. Sets are composed
228
+ * library-before-app by `order` (like migrations) and each declares the environments it may be
229
+ * seeded into — the first layer of the env-safety model. `production` is seeded only when a set
230
+ * lists it explicitly.
231
+ */
232
+ export interface SeedSet {
233
+ /** Stable, unique-per-capability name. Namespaced by capability name when composed. */
234
+ name: string;
235
+ /** Sort order across capabilities (libraries low, app high). Ties break on the namespaced key. */
236
+ order: number;
237
+ /** The environments this set may be seeded into. A set is skipped for any env it does not list. */
238
+ environments: readonly SeedEnv[];
239
+ /**
240
+ * The directory the set's relative media paths (`MediaSeedItem.file` / `.ref`) resolve against —
241
+ * the seed module's own directory, so fixture bytes sit next to the module that declares them.
242
+ * Author it as `baseDir: import.meta.dirname`. When omitted, media paths resolve against the
243
+ * project root instead (a legacy fallback, rarely what you want once a set ships media).
244
+ */
245
+ baseDir?: string;
246
+ /** Whether this is an example/demo set — composed only when the project enables `includeExamples`. */
247
+ example?: boolean;
248
+ /** D1 row groups. */
249
+ d1?: readonly D1SeedGroup[];
250
+ /** KV entry groups. */
251
+ kv?: readonly KvSeedGroup[];
252
+ /** R2 objects. */
253
+ r2?: readonly R2SeedItem[];
254
+ /** Media assets (Images/Stream), uploaded by the CLI. */
255
+ media?: readonly MediaSeedItem[];
256
+ /**
257
+ * Late-bound fixtures: run once per Worker, just before this set's rows are written, to compute what a
258
+ * static literal cannot hold. Its groups are appended to the static ones and go through the same
259
+ * `schema.encode` validation; its artifacts are written after.
260
+ *
261
+ * A dry run never calls it. A plan touches no backend and needs no credentials, so a prepared set
262
+ * reports nothing there rather than reading a secret to describe work it is not going to do.
263
+ */
264
+ prepare?: (context: SeedPrepareContext) => Promise<SeedPreparation>;
265
+ }
266
+
267
+ /**
268
+ * Author a seed set. A pass-through that anchors the {@link SeedSet} type at the author site (the
269
+ * peer of `defineCapability`); the real invariant checks (namespacing, ordering, env filtering)
270
+ * run in `composeSeeds`, and the write-time validation runs in `seedD1Group`. Use {@link d1SeedGroup}
271
+ * and {@link kvSeedGroup} to bind rows/entries to their schemas at compile time.
272
+ */
273
+ export function defineSeed(input: SeedSet): SeedSet {
274
+ return input;
275
+ }
276
+
277
+ /**
278
+ * Author a typed D1 seed group. `schema` is a compile-time carrier only — it binds `rows` to the
279
+ * table's `z.output` (app) shape so a mistyped fixture fails to compile; it is not stored on the
280
+ * group (the writer resolves the schema from the composed databases registry). Mirrors
281
+ * `createDatabase`'s type-only `map` argument.
282
+ */
283
+ export function d1SeedGroup<Table extends z.ZodType>(
284
+ database: string,
285
+ table: string,
286
+ schema: Table,
287
+ rows: readonly z.output<Table>[],
288
+ ): D1SeedGroup<Table> {
289
+ void schema; // type-only carrier; see doc comment
290
+ return { database, table, rows };
291
+ }
292
+
293
+ /**
294
+ * Author a typed KV seed group. `spec` is a compile-time carrier only — it binds each entry's
295
+ * `key`/`value`/`metadata` to the store's schemas so a mistyped fixture fails to compile; it is not
296
+ * stored on the group (the writer supplies the live store).
297
+ */
298
+ export function kvSeedGroup<V extends z.ZodType, K extends z.ZodObject, M extends z.ZodType>(
299
+ namespace: string,
300
+ store: string,
301
+ spec: KvStoreSpec<V, K, M>,
302
+ entries: readonly KvSeedEntry<V, K, M>[],
303
+ ): KvSeedGroup<V, K, M> {
304
+ void spec; // type-only carrier; see doc comment
305
+ return { namespace, store, entries };
306
+ }
@@ -0,0 +1,41 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SeedSet } from "./seed";
5
+
6
+ /**
7
+ * The run's own inventory: which rows a seed run declares, per table, before it writes any of them.
8
+ *
9
+ * It exists for the one class of fixture that has to agree with a row another set owns — a dev session for a
10
+ * seeded user, say. Such a set cannot hard-code the roster (the users may be an adopter's, not a library's)
11
+ * and must not read the database (a capability module is bundled into the Worker, where D1 is a binding it
12
+ * has no business opening during a seed). So the CLI hands it the composed registry it already holds in
13
+ * memory. It is a *plan*, not a query: no I/O, no credentials, and the same answer in a dry run.
14
+ */
15
+
16
+ /**
17
+ * Read the app-shape rows this run declares for one `database`.`table`, across every composed set. Empty
18
+ * when nothing seeds that table. Values are `unknown` on purpose — they cross into a capability that does
19
+ * not own the table, so the reader validates them with the schema it expects.
20
+ */
21
+ export type SeededRows = (database: string, table: string) => readonly unknown[];
22
+
23
+ /**
24
+ * Index every set's statically declared D1 rows into one lookup, in composed order.
25
+ *
26
+ * Statically declared, and only that. A prepared set's rows do not exist until its own `prepare` runs, and a
27
+ * media record's row has no id until the CLI mints one — reporting either would promise a row that may never
28
+ * land. A set that needs to be *seen* must therefore declare its rows as literals, which every user fixture
29
+ * does.
30
+ */
31
+ export function collectSeededRows(sets: readonly SeedSet[]): SeededRows {
32
+ const index = new Map<string, Map<string, unknown[]>>();
33
+ for (const set of sets) {
34
+ for (const group of set.d1 ?? []) {
35
+ const tables = index.get(group.database) ?? new Map<string, unknown[]>();
36
+ index.set(group.database, tables);
37
+ tables.set(group.table, [...(tables.get(group.table) ?? []), ...group.rows]);
38
+ }
39
+ }
40
+ return (database, table) => index.get(database)?.get(table) ?? [];
41
+ }
@@ -0,0 +1,103 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import { z } from "zod";
6
+ import { chunkRowsByBoundParameters } from "../data/boundParameters";
7
+ import { fromZodError } from "../error/pithyError";
8
+ import type { D1SeedGroup } from "./seed";
9
+
10
+ /** Options for {@link seedD1Group}. */
11
+ export interface SeedD1Options {
12
+ /** Validate + count only; perform no write. */
13
+ dryRun?: boolean;
14
+ }
15
+
16
+ /** The outcome of seeding one D1 group. */
17
+ export interface SeedD1Result {
18
+ /** The table seeded. */
19
+ table: string;
20
+ /** The number of rows validated (and, unless `dryRun`, written). */
21
+ rows: number;
22
+ }
23
+
24
+ /**
25
+ * How many parameters one encoded row will bind.
26
+ *
27
+ * The **union** of every row's keys, not the first row's count: Kysely builds one column list for a
28
+ * multi-row insert from every object it was given, so a group whose rows differ binds against the wider
29
+ * list. Over-counting only makes the chunks smaller, which is always safe; under-counting is the bug.
30
+ *
31
+ * A row that is not an object cannot be an insert record at all, and Kysely will say so far more
32
+ * usefully than an arithmetic helper would — so it is counted as one parameter and left to fail there.
33
+ */
34
+ function columnsPerRow(encoded: readonly unknown[]): number {
35
+ const columns = new Set<string>();
36
+ for (const row of encoded) {
37
+ if (typeof row !== "object" || row === null) return 1;
38
+ for (const key of Object.keys(row)) columns.add(key);
39
+ }
40
+ return Math.max(columns.size, 1);
41
+ }
42
+
43
+ /**
44
+ * Seed one D1 group, idempotently and non-destructively.
45
+ *
46
+ * Every row is `schema.encode(row)`d first — this IS the write-time Zod validation boundary. An
47
+ * invalid fixture throws a `ValidationError` (a `ZodError` mapped via `fromZodError`) **before any
48
+ * write for this group**, so a partial group never lands. The insert is `INSERT OR IGNORE`
49
+ * (`orIgnore`), so re-running seeds writes no duplicate rows and never overwrites existing data. The
50
+ * table's row schema is resolved by the caller from the composed databases registry and passed in —
51
+ * the fixture never redeclares it.
52
+ *
53
+ * **The group is written in chunks sized against D1's bound-parameter ceiling, and that belongs here
54
+ * rather than at any call site.** An insert binds one parameter per column per row, so a group's real
55
+ * limit is `100 / columns` — around fifteen rows for a seven-column table — and it moves with the
56
+ * table. Every fixture the kit ships is 2–6 rows, which is why the single unbatched insert survived: it
57
+ * only breaks on a fixture big enough to do the job fixtures exist for, and `DEFAULT_PAGE_SIZE` is 25,
58
+ * so anything proving a paged list crosses the limit by construction. `pithy-sh/dashboard` hit it on
59
+ * its first realistic seed and worked around it by splitting fourteen tables into twenty-nine groups by
60
+ * hand. A rule that every adopter re-derives after a confusing `too many SQL variables` is a rule in
61
+ * the wrong place; the size is derived from the row's own column count, never guessed.
62
+ *
63
+ * Chunks are written in sequence, and a group is deliberately not atomic across them. `INSERT OR
64
+ * IGNORE` makes a partial run safe to re-run — the landed chunks collide and are ignored — which is the
65
+ * same property that makes seeding re-runnable at all.
66
+ *
67
+ * `dryRun` validates and counts but writes nothing.
68
+ */
69
+ export async function seedD1Group(
70
+ // biome-ignore lint/suspicious/noExplicitAny: the group's table is a runtime string, so the DB is the erased row universe; the row schema validates the fixture.
71
+ db: Kysely<any>,
72
+ group: D1SeedGroup,
73
+ schema: z.ZodType,
74
+ options: SeedD1Options = {},
75
+ ): Promise<SeedD1Result> {
76
+ const encoded = group.rows.map((row, index) => {
77
+ try {
78
+ return schema.encode(row);
79
+ } catch (error) {
80
+ if (error instanceof z.ZodError) {
81
+ throw fromZodError(error, {
82
+ message: `Invalid seed row for table "${group.table}".`,
83
+ action: "Fix the fixture so every field matches the table schema.",
84
+ detail: `Row ${index} for database "${group.database}" table "${group.table}" failed encode validation.`,
85
+ });
86
+ }
87
+ throw error;
88
+ }
89
+ });
90
+
91
+ if (options.dryRun || encoded.length === 0) {
92
+ return { table: group.table, rows: encoded.length };
93
+ }
94
+
95
+ for (const chunk of chunkRowsByBoundParameters(encoded, columnsPerRow(encoded))) {
96
+ await db
97
+ .insertInto(group.table)
98
+ .orIgnore()
99
+ .values(chunk as never)
100
+ .execute();
101
+ }
102
+ return { table: group.table, rows: encoded.length };
103
+ }
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { fromZodError } from "../error/pithyError";
6
+ import type { TypedKv } from "../kv/kv";
7
+ import type { KvStoreSpec } from "../kv/namespaces";
8
+ import type { KvSeedGroup } from "./seed";
9
+
10
+ /** Options for {@link seedKvGroup}. */
11
+ export interface SeedKvOptions {
12
+ /** Count only; perform no write. */
13
+ dryRun?: boolean;
14
+ }
15
+
16
+ /** The outcome of seeding one KV group. */
17
+ export interface SeedKvResult {
18
+ /** The store seeded. */
19
+ store: string;
20
+ /** The number of entries validated (and, unless `dryRun`, considered for writing). */
21
+ entries: number;
22
+ }
23
+
24
+ /**
25
+ * Validate one entry's key, value, and (present) metadata against the store spec, mapping a
26
+ * `ZodError` into an actionable `ValidationError`. Pure — never writes.
27
+ */
28
+ function validateEntry<V extends z.ZodType, K extends z.ZodObject, M extends z.ZodType>(
29
+ spec: KvStoreSpec<V, K, M>,
30
+ group: KvSeedGroup<V, K, M>,
31
+ entry: KvSeedGroup<V, K, M>["entries"][number],
32
+ index: number,
33
+ ): void {
34
+ try {
35
+ spec.key.parse(entry.key);
36
+ spec.value.parse(entry.value);
37
+ if (entry.metadata !== undefined && spec.metadata !== undefined) spec.metadata.parse(entry.metadata);
38
+ } catch (error) {
39
+ if (error instanceof z.ZodError) {
40
+ throw fromZodError(error, {
41
+ message: `Invalid seed entry for KV store "${group.store}".`,
42
+ action: "Fix the fixture so every entry matches the store's key, value, and metadata schemas.",
43
+ detail: `Entry ${index} for namespace "${group.namespace}" store "${group.store}" failed validation.`,
44
+ });
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Whether `key` already holds a value — used to make the write non-destructive. A value that exists but
52
+ * no longer parses against the store schema (foreign or older data at a colliding key) still counts as
53
+ * present: existence, not readability, is what the "never overwrite" guarantee turns on.
54
+ */
55
+ async function keyExists<V extends z.ZodType, K extends z.ZodObject, M extends z.ZodType>(
56
+ store: TypedKv<V, K, M>,
57
+ key: z.input<K>,
58
+ ): Promise<boolean> {
59
+ try {
60
+ return (await store.get(key)) !== null;
61
+ } catch {
62
+ return true;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Seed one KV group, idempotently and non-destructively.
68
+ *
69
+ * **Every entry is validated against the store spec before any write** — mirroring `seedD1Group`, an
70
+ * invalid fixture throws a `ValidationError` (a `ZodError` mapped via `fromZodError`) before the first
71
+ * `put`, so a partial group never lands. The write itself is non-destructive: an existing key is left
72
+ * untouched (KV's analog of D1's `INSERT OR IGNORE`), so re-running a seed never overwrites real data
73
+ * that happens to share a key, and re-establishes only the keys it owns.
74
+ *
75
+ * `dryRun` validates and counts but writes nothing.
76
+ */
77
+ export async function seedKvGroup<V extends z.ZodType, K extends z.ZodObject, M extends z.ZodType>(
78
+ store: TypedKv<V, K, M>,
79
+ group: KvSeedGroup<V, K, M>,
80
+ spec: KvStoreSpec<V, K, M>,
81
+ options: SeedKvOptions = {},
82
+ ): Promise<SeedKvResult> {
83
+ // Validate the whole group up front, before any write, so an invalid entry never leaves earlier
84
+ // entries persisted.
85
+ group.entries.forEach((entry, index) => {
86
+ validateEntry(spec, group, entry, index);
87
+ });
88
+
89
+ if (options.dryRun) {
90
+ return { store: group.store, entries: group.entries.length };
91
+ }
92
+
93
+ for (const entry of group.entries) {
94
+ // Never overwrite an existing key — the KV counterpart of D1's INSERT OR IGNORE.
95
+ if (await keyExists(store, entry.key)) continue;
96
+ await store.put(entry.key, entry.value, entry.metadata !== undefined ? { metadata: entry.metadata } : {});
97
+ }
98
+ return { store: group.store, entries: group.entries.length };
99
+ }