@isomorph.ai/cli 0.4.2 → 0.5.1

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.
@@ -1,443 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { allowedLiterals, hasNumericMaximum, numericMinimum, textLengthBounds } from "./app-schema.js";
5
- import { COVERED_CAPABILITIES, capabilitiesCalled, capabilityCallSurface, capabilityUsage, sourceTableUsage } from "./source-inventory.js";
6
- import { kitPaths } from "./kit.js";
7
- /**
8
- * Retained checks, generated from what the app declares.
9
- *
10
- * `.isomorph/checks/` used to be shipped by `isomorph init` as three fixed files
11
- * written for the starter's own notes/files example. The first thing a builder
12
- * does is replace that example, and the checks stayed — exercising tables and a
13
- * capability the app no longer had, which is exactly what the pipeline's `flow`
14
- * gate refuses (`flow.check-failed`). It cost two real builder runs on one day:
15
- * a todo app that dropped the files UI and renamed notes to todos failed on
16
- * `files-journey.mjs` ("application capability is not enabled") and on
17
- * `notes-cross-user.mjs` ("relation … does not exist").
18
- *
19
- * So there are no templates. The two readings the `flow` gate already makes of
20
- * the app's own code — `sourceTableUsage` (every `<table> <verb>` the browser
21
- * code performs) and `capabilityUsage` (every SDK namespace it calls) — decide
22
- * which checks exist, and the app's own database, the one `isomorph check` has
23
- * just replayed `migrations/*.sql` into, supplies the row a journey writes. A
24
- * check exists for each capability the app actually has and for none it does
25
- * not, by construction rather than by guidance.
26
- *
27
- * Three rules keep that safe:
28
- *
29
- * - **Isomorph only manages what Isomorph wrote.** A generated file carries a
30
- * digest of its own body on its first line. `isomorph check` rewrites and
31
- * deletes a file whose digest still matches; the moment a builder edits one
32
- * — or writes their own — it is theirs, and Isomorph reports it instead of
33
- * touching it. That was #456's lesson: the remediation we hand out must
34
- * never overwrite the app.
35
- * - **Isomorph never writes a check its own gate would refuse.** The `flow`
36
- * gate refuses a retained check exercising a capability no code in the app
37
- * calls (#469), reading that surface with `capabilityCallSurface`; nothing
38
- * is generated for a capability outside it.
39
- * - **The `flow` gate is untouched.** Generation removes the drift; the gate
40
- * still decides. Surface Isomorph cannot write a journey for is named with
41
- * the reason, and `flow` fails exactly as it did before.
42
- */
43
- /** First line of every file Isomorph wrote, carrying the digest of the body below it. */
44
- const GENERATED_PREFIX = "// isomorph:generated sha256:";
45
- /** Capability names that own a fixed file name, so a table of the same name does not shadow one. */
46
- const RESERVED_NAMES = new Set(["files", "telemetry", "actions", "realtime", "ai"]);
47
- /** The data verbs a generated journey performs, in the order it performs them — the set `source-inventory.ts` inventories. */
48
- const VERB_ORDER = ["insert", "upsert", "select", "update", "delete"];
49
- /**
50
- * Regenerates `.isomorph/checks/` in place from the app's code and its own
51
- * database, and reports every decision. `declared`, when given, is the
52
- * capability set the kit gate derived from the source — the set its coverage
53
- * gate will demand evidence for — and it is the authority: the kit lane and
54
- * this reading agree on the one call shape the starter teaches
55
- * (`isomorph.<namespace>.<method>(`), for which the lane's derivation is exact.
56
- * Without it (tests, no gate) the source reading decides.
57
- */
58
- export async function syncRetainedChecks(root, schema, declared) {
59
- const plan = await planRetainedChecks(root, schema, declared);
60
- const created = [];
61
- const updated = [];
62
- for (const check of plan.write) {
63
- const absolute = join(root, check.path);
64
- const current = await readFile(absolute, "utf8").catch(() => undefined);
65
- if (current === check.content)
66
- continue;
67
- await mkdir(kitPaths(root).checks, { recursive: true });
68
- await writeFile(absolute, check.content);
69
- (current === undefined ? created : updated).push(check.path);
70
- }
71
- for (const path of plan.remove)
72
- await rm(join(root, path), { force: true });
73
- return { created, updated, removed: [...plan.remove], adopted: plan.adopted, blocked: plan.blocked, orphaned: plan.orphaned };
74
- }
75
- /** One line per decision, for `isomorph check`'s output. */
76
- export function describeRetainedChecks(result) {
77
- return [
78
- ...result.created.map(path => `generated ${path}`),
79
- ...result.updated.map(path => `regenerated ${path}`),
80
- ...result.removed.map(path => `removed ${path} — the app's code no longer does what it exercised, and the pipeline's flow gate refuses a check that does`),
81
- ...result.orphaned.map(detail => `keeping ${detail}`),
82
- ...result.blocked.map(detail => `no check generated for ${detail}`)
83
- ];
84
- }
85
- /** Reads the app and decides what `.isomorph/checks/` should hold, writing nothing. */
86
- export async function planRetainedChecks(root, schema, declared) {
87
- const [tables, used, surface, existing] = await Promise.all([sourceTableUsage(root), capabilityUsage(root), capabilityCallSurface(root), readChecks(root)]);
88
- // The gate's derivation, when it spoke, names the capabilities; the source
89
- // reading keeps the file attribution where it has one.
90
- const capabilities = declared ? new Map(declared.map(capability => [capability, used.get(capability) ?? ["the app's source, as the kit lane derives it"]])) : used;
91
- const gateDeclared = (capability) => declared !== undefined && declared.includes(capability);
92
- const adopted = existing.filter(check => !check.pristine);
93
- const plan = { write: [], remove: [], adopted: adopted.map(check => check.path), blocked: [], orphaned: [] };
94
- const planned = new Set();
95
- /**
96
- * A planned check, named by the one capability it exercises: outside the
97
- * `flow` gate's own surface, Isomorph writes nothing. And never over a file
98
- * the builder owns: an adopted check under the name Isomorph would use is
99
- * theirs, however little of the table it still exercises — the gate says
100
- * what it misses, and only its author decides.
101
- */
102
- const add = (capability, name, content) => {
103
- if (!surface.has(capability) && !gateDeclared(capability))
104
- return;
105
- if (adopted.some(check => check.name === name))
106
- return;
107
- planned.add(name);
108
- plan.write.push({ path: `.isomorph/checks/${name}`, content });
109
- };
110
- for (const [table, usage] of [...tables].sort(([first], [second]) => first.localeCompare(second))) {
111
- const verbs = VERB_ORDER.filter(verb => usage.verbs.has(verb));
112
- const declared = schema.get(table);
113
- // A check the builder owns covering every verb is the coverage, and one acting as the
114
- // second person is the cross-user coverage. The two are decided separately: editing a
115
- // journey must not take its table's cross-user denial down with it.
116
- const journeyCovered = verbs.length > 0 && verbs.every(verb => adopted.some(check => coversTableVerb(check.text, table, verb)));
117
- const crossUserCovered = adopted.some(check => actsAsSecondUser(check.text) && coversTableVerb(check.text, table, "select"));
118
- if (journeyCovered && (crossUserCovered || !declared?.ownerScoped))
119
- continue;
120
- const reason = (detail) => plan.blocked.push(`\`${table}\` (${verbs.join(", ")}), which ${usage.files.join(", ")} queries: ${detail}`);
121
- if (!declared) {
122
- reason("no table of that name exists once the app's migrations have replayed, so Isomorph cannot know what a row of it looks like");
123
- continue;
124
- }
125
- const shape = verbs.some(verb => verb !== "select") ? rowShape(declared, verbs.includes("update")) : undefined;
126
- if (shape && "blocked" in shape) {
127
- reason(shape.blocked);
128
- continue;
129
- }
130
- if (!journeyCovered)
131
- add("data", fileName(table, "journey"), dataJourney(table, verbs, declared, usage.files, shape));
132
- if (declared.ownerScoped && shape && !crossUserCovered)
133
- add("data", fileName(table, "cross-user"), crossUserCheck(table, declared, shape, usage.files));
134
- }
135
- for (const capability of COVERED_CAPABILITIES) {
136
- const files = capabilities.get(capability);
137
- if (!files || capability === "data")
138
- continue;
139
- if (adopted.some(check => capabilitiesCalled(check.text).has(capability)))
140
- continue;
141
- const generic = capability === "files" ? filesJourney(files) : capability === "telemetry" ? telemetryJourney(files) : capability === "ai" ? aiJourney(files) : undefined;
142
- if (!generic) {
143
- plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Isomorph writes journeys for data, files, telemetry and ai only — write \`.isomorph/checks/${capability}-journey.mjs\` yourself${capability === "realtime" ? " (the filter and payload shape: core.md in the isomorph skill)" : ""}`);
144
- continue;
145
- }
146
- add(capability, `${capability}-journey.mjs`, generic);
147
- }
148
- for (const check of existing) {
149
- if (check.pristine) {
150
- if (!planned.has(check.name))
151
- plan.remove.push(check.path);
152
- continue;
153
- }
154
- const stale = staleSurface(check.text, tables, capabilities);
155
- if (stale)
156
- plan.orphaned.push(`${check.path} — Isomorph did not write this file, so it is left alone, but it exercises ${stale}; delete it or restore the feature, or the pipeline's flow gate refuses the app`);
157
- }
158
- plan.write.sort((first, second) => first.path.localeCompare(second.path));
159
- plan.remove.sort();
160
- return plan;
161
- }
162
- // ---- Generated files ------------------------------------------------------------
163
- /** The paragraph every generated check carries: what wrote it, what maintains it, and what an edit means. */
164
- function stewardship(sources, uuid = false) {
165
- return [
166
- "//",
167
- `// Generated by Isomorph from ${sources.join(" and ")}, and kept in step by`,
168
- "// `isomorph check`: rewritten when the app's schema or the operations it performs",
169
- "// change, and deleted when the app stops performing them. Edit this file and",
170
- "// Isomorph stops managing it — your version is kept from then on, and keeping it",
171
- "// honest becomes yours: the deployment pipeline replays `.isomorph/checks/` against",
172
- "// a real App Gateway and refuses the app (`flow.check-failed`) when a check",
173
- "// exercises something the code no longer does, so DELETE THIS FILE in the same",
174
- "// edit that removes the feature it covers.",
175
- "",
176
- ...(uuid ? ["import { randomUUID } from \"node:crypto\";"] : []),
177
- "import assert from \"node:assert/strict\";",
178
- "",
179
- "const appUrl = process.env.ISOMORPH_APP_URL;",
180
- "assert.ok(appUrl, \"ISOMORPH_APP_URL is required\");",
181
- "const sdk = await import(process.env.ISOMORPH_SDK_MODULE ?? \"@isomorph.ai/app-sdk\");"
182
- ];
183
- }
184
- /** The single signed-in identity every journey but the cross-user one runs as. */
185
- const SIGNED_IN = [
186
- "// The signed-in identity is attached by the SDK itself, from the kit gate's",
187
- "// runner (the same under `isomorph check` and in the pipeline): one injector per request.",
188
- "const isomorph = sdk.createClient({ baseUrl: appUrl });",
189
- "",
190
- "const user = await isomorph.identity.current();",
191
- "assert.ok(user && user.email, \"identity/current must return the signed-in user\");"
192
- ];
193
- /** A table's journey: the write steps when the app writes to it, a plain read when it only reads. */
194
- function dataJourney(table, verbs, schema, sources, row) {
195
- const body = [
196
- `// Retained journey for the \`${table}\` table: the signed-in identity can`,
197
- `// ${sentence(verbs)} a row through the running app (ISOMORPH_APP_URL) — the operations`,
198
- `// ${sources.join(", ")} performs on this table. Runs under \`isomorph check\` while`,
199
- "// `isomorph dev` is up, and in the deployment pipeline's retained-check container.",
200
- ...stewardship([schema.file, ...sources], row?.uuid ?? false),
201
- ...SIGNED_IN,
202
- ...(row ? writeSteps(table, verbs, row) : [
203
- "",
204
- `const { data: rows } = await isomorph.data.from("${table}").select("*").limit(1);`,
205
- `assert.ok(Array.isArray(rows), "${table}: the signed-in identity can read the table this app reads");`
206
- ])
207
- ];
208
- return sign(`${body.join("\n")}\n`);
209
- }
210
- function writeSteps(table, verbs, row) {
211
- const from = `isomorph.data.from("${table}")`;
212
- const at = `.eq("${row.locator}", row.${row.locator})`;
213
- const steps = [
214
- "",
215
- `const markerValue = ${row.markerExpression};`,
216
- `await ${from}.insert({ ${row.values.join(", ")} });`,
217
- `const { data: rows } = await ${from}.select("*").eq("${row.marker.name}", markerValue);`,
218
- `assert.equal(rows.length, 1, "the inserted ${table} row is readable by its owner");`,
219
- "const row = rows[0];"
220
- ];
221
- if (verbs.includes("upsert"))
222
- steps.push("", `await ${from}.upsert({ ...row, ${row.marker.name}: markerValue });`, `const { data: upserted } = await ${from}.select("*").eq("${row.marker.name}", markerValue);`, `assert.ok(upserted.length >= 1, "the upserted ${table} row is readable by its owner");`);
223
- if (verbs.includes("update") && row.update)
224
- steps.push("", `await ${from}.update({ ${row.update.column.name}: ${row.update.expression} })${at};`, `const { data: updated } = await ${from}.select("*")${at};`, `assert.deepEqual(updated[0].${row.update.column.name}, ${row.update.expression}, "the ${table} row is updated for its owner");`);
225
- if (verbs.includes("delete"))
226
- steps.push("", `await ${from}.delete()${at};`, `const { data: remaining } = await ${from}.select("*")${at};`, `assert.equal(remaining.length, 0, "the ${table} row is deleted");`);
227
- return steps;
228
- }
229
- function crossUserCheck(table, schema, row, sources) {
230
- const mutated = row.update ? { column: row.update.column.name, value: row.update.expression } : { column: row.marker.name, value: row.markerExpression };
231
- const body = [
232
- "// Cross-user check: a second signed-in person cannot read, update or delete the first",
233
- `// person's \`${table}\` row through the running app (ISOMORPH_APP_URL). The deployment`,
234
- "// pipeline's write probe makes exactly these three assertions, with a second identity,",
235
- `// against every owner-scoped table, and \`${table}\` is one: ${schema.file} scopes its rows`,
236
- `// with current_setting('harbour.user_id')${schema.ownerColumn ? ` on \`${schema.ownerColumn}\`` : ""}. Running it here means user isolation can`,
237
- "// no longer be green under `isomorph check` and red in CodeBuild.",
238
- ...stewardship([schema.file, ...sources], row.uuid),
239
- "// Both people come from the SDK itself: the kit gate's runner signs two",
240
- "// identities and the SDK attaches the first to every request by default and",
241
- "// the second when asked. A check never reads a token name.",
242
- "const owner = sdk.createClient({ baseUrl: appUrl });",
243
- "const other = sdk.createClient({ baseUrl: appUrl, identity: \"second-user\" });",
244
- "",
245
- "const me = await owner.identity.current();",
246
- "const them = await other.identity.current();",
247
- "assert.ok(me && them && me.id !== them.id, \"the two signed-in identities must be different people\");",
248
- "",
249
- `const markerValue = ${row.markerExpression};`,
250
- `await owner.data.from("${table}").insert({ ${row.values.join(", ")} });`,
251
- `const { data: mine } = await owner.data.from("${table}").select("*").eq("${row.marker.name}", markerValue);`,
252
- `assert.equal(mine.length, 1, "the owner reads the ${table} row back");`,
253
- "const row = mine[0];",
254
- "",
255
- "// 1. SELECT by key as the second person: no row.",
256
- `const { data: seen } = await other.data.from("${table}").select("*").eq("${row.locator}", row.${row.locator});`,
257
- `assert.equal(seen.length, 0, "${table}: a second signed-in user can read another user's row — the owner policy does not isolate users — rule core.rls-owner-scoped (core.md in the isomorph skill)");`,
258
- "",
259
- "// 2. UPDATE and 3. DELETE by key as the second person: refused (403) or no row affected.",
260
- "const denied = async (verb, operation) => {",
261
- " let affected;",
262
- " try { affected = (await operation()).data ?? []; }",
263
- " catch (error) { if (error?.category === \"FORBIDDEN\") return; throw error; }",
264
- ` assert.equal(affected.length, 0, \`${table} \${verb}: a second user mutated another user's row despite an owner-scoped source policy\`);`,
265
- "};",
266
- `await denied("UPDATE", () => other.data.from("${table}").update({ ${mutated.column}: ${mutated.value} }).eq("${row.locator}", row.${row.locator}));`,
267
- `await denied("DELETE", () => other.data.from("${table}").delete().eq("${row.locator}", row.${row.locator}));`,
268
- `const { data: after } = await owner.data.from("${table}").select("*").eq("${row.locator}", row.${row.locator});`,
269
- `assert.equal(after.length, 1, "the owner's ${table} row still exists after the second user's attempts");`,
270
- `assert.deepEqual(after[0].${mutated.column}, row.${mutated.column}, "the owner's ${table} row is unchanged after the second user's attempts");`,
271
- `await owner.data.from("${table}").delete().eq("${row.locator}", row.${row.locator});`
272
- ];
273
- return sign(`${body.join("\n")}\n`);
274
- }
275
- function filesJourney(sources) {
276
- const body = [
277
- "// Retained journey for the app's private files: the signed-in identity can upload a",
278
- "// file and list it back through the running app (ISOMORPH_APP_URL) — the operations",
279
- `// ${sources.join(", ")} performs through \`isomorph.files.*\`. The deployment pipeline`,
280
- "// refuses an app whose checks never exercise a capability its own code uses, so this",
281
- "// runs under `isomorph check` while `isomorph dev` is up, and in the pipeline's container.",
282
- ...stewardship(sources),
283
- ...SIGNED_IN,
284
- "",
285
- "const name = `isomorph-journey-${Date.now()}.txt`;",
286
- "await isomorph.files.upload(`private/${name}`, new Blob([\"isomorph journey check\\n\"], { type: \"text/plain\" }));",
287
- "const listed = await isomorph.files.list(\"private/\");",
288
- "assert.ok(Array.isArray(listed), \"files.list must return a list\");",
289
- "const found = listed.some(entry => String(typeof entry === \"string\" ? entry : entry?.path ?? entry?.name ?? \"\").endsWith(name));",
290
- "assert.ok(found, `the uploaded file is listed back to its owner (looking for ${name})`);"
291
- ];
292
- return sign(`${body.join("\n")}\n`);
293
- }
294
- function telemetryJourney(sources) {
295
- const body = [
296
- "// Retained journey for the app's telemetry: one event reaches the gateway through the",
297
- `// running app (ISOMORPH_APP_URL) — the capability ${sources.join(", ")} uses through`,
298
- "// `isomorph.telemetry.*`. The deployment pipeline refuses an app whose checks never",
299
- "// exercise a capability its own code uses, so this runs under `isomorph check` while",
300
- "// `isomorph dev` is up, and in the pipeline's retained-check container.",
301
- ...stewardship(sources),
302
- ...SIGNED_IN,
303
- "",
304
- "// `track` resolves with nothing and throws on a refusal, so reaching the next line is the assertion.",
305
- "await isomorph.telemetry.track(\"isomorph_retained_check\", { source: \"telemetry-journey\" });"
306
- ];
307
- return sign(`${body.join("\n")}\n`);
308
- }
309
- function aiJourney(sources) {
310
- const body = [
311
- "// Retained journey for the app's governed AI: one chat completion reaches the gateway",
312
- `// through the running app (ISOMORPH_APP_URL) — the capability ${sources.join(", ")} uses`,
313
- "// through `isomorph.ai.chat`. Locally this is a real governed call through the Isomorph",
314
- "// development route (your `isomorph login`; IT must have enabled an AI provider). In the",
315
- "// deployment pipeline it is answered by a canned completion and reported as not tested.",
316
- "// The pipeline refuses an app whose checks never exercise a capability its code uses.",
317
- ...stewardship(sources),
318
- ...SIGNED_IN,
319
- "",
320
- "const reply = await isomorph.ai.chat({ messages: [{ role: \"user\", content: \"Reply with one word: ready\" }], maxTokens: 16 });",
321
- "assert.equal(typeof reply.content, \"string\", \"ai.chat must answer with content\");",
322
- "assert.ok(reply.content.length > 0, \"the governed completion is not empty\");",
323
- "assert.equal(typeof reply.model, \"string\", \"the answer names the governed model alias\");"
324
- ];
325
- return sign(`${body.join("\n")}\n`);
326
- }
327
- // ---- Reading the app ------------------------------------------------------------
328
- /** How a generated journey writes one row: the values to insert, the column it finds the row by, and the column it changes. */
329
- function rowShape(schema, needsUpdate) {
330
- const writable = schema.columns.filter(column => !column.generated && !column.identityDefault);
331
- const required = writable.filter(column => column.notNull && !column.hasDefault);
332
- const unwritable = required.find(column => column.references) ?? required.find(column => !fillExpression(column));
333
- if (unwritable)
334
- return { blocked: `its \`${unwritable.name} ${unwritable.declaredType}\` column ${unwritable.references ? "references another table, so a row cannot be written without inventing its parent — rule core.journey-fk (core.md in the isomorph skill)" : "has a type Isomorph cannot invent a value for"}` };
335
- const marker = writable.find(column => isMarker(column) && column.notNull && !column.hasDefault) ?? writable.find(isMarker);
336
- if (!marker)
337
- return { blocked: markerless(writable) };
338
- const markerExpression = marker.type === "text" ? textMarker(marker) : "Date.now() % 2000000000";
339
- if (!markerExpression)
340
- return { blocked: `\`${marker.name}\` is the only column a check could find a row by, and its CHECK constraint leaves no room for a unique value` };
341
- const locator = schema.primaryKey.length === 1 && schema.primaryKey[0] !== marker.name ? schema.primaryKey[0] : marker.name;
342
- const fill = required.filter(column => column.name !== marker.name).map(column => `${column.name}: ${fillExpression(column)}`);
343
- const update = needsUpdate ? updateStep(writable, marker, locator) : undefined;
344
- if (needsUpdate && !update)
345
- return { blocked: "the app updates it, but every column Isomorph could change is the one the check finds the row by" };
346
- return { values: [`${marker.name}: markerValue`, ...fill], marker, markerExpression, locator, uuid: fill.some(value => value.includes("randomUUID(")), update };
347
- }
348
- /** Why nothing on the table can carry the unique value a journey finds its row by — naming the capped number when that is what ruled the last candidate out, so the answer is a column to add rather than a CHECK to weaken. */
349
- function markerless(writable) {
350
- const none = "it has no free text or number column a check could write a unique value into and find the row by";
351
- const capped = writable.find(cappedNumber);
352
- return capped ? `${none}: \`${capped.name} ${capped.declaredType}\` is a number its own CHECK caps, and the unique value a journey writes is a timestamp` : none;
353
- }
354
- /** What a journey writes into a free-text column it updates, when the column's length bounds admit it. */
355
- const UPDATED_TEXT = "isomorph check updated";
356
- /** The column a journey changes, and what it changes it to: a boolean is flipped, a closed set moves to its other value, free text gets a fixed one. */
357
- function updateStep(writable, marker, locator) {
358
- const candidates = writable.filter(column => column.name !== locator && column.name !== marker.name);
359
- const flag = candidates.find(column => column.type === "boolean");
360
- if (flag)
361
- return { column: flag, expression: `!row.${flag.name}` };
362
- for (const column of candidates) {
363
- const literals = allowedLiterals(column.checks, column.name);
364
- if (literals.length >= 2)
365
- return { column, expression: JSON.stringify(literals[1]) };
366
- if (column.type !== "text" || literals.length)
367
- continue;
368
- const { min, max } = textLengthBounds(column.checks, column.name);
369
- if (max < UPDATED_TEXT.length || min > UPDATED_TEXT.length)
370
- continue;
371
- return { column, expression: JSON.stringify(UPDATED_TEXT) };
372
- }
373
- return undefined;
374
- }
375
- /** A number the app's own CHECK caps from above: the unique value a journey marks a row with is a timestamp, which no ordinary ceiling admits, so such a column is never the marker. */
376
- const cappedNumber = (column) => (column.type === "integer" || column.type === "number") && hasNumericMaximum(column.checks, column.name);
377
- /** A column a check can write a unique value into and then filter on. */
378
- const isMarker = (column) => (column.type === "text" || column.type === "integer" || column.type === "number") && !column.references && !cappedNumber(column) && !allowedLiterals(column.checks, column.name).length;
379
- /** A unique marker satisfying the column's own length constraints, or `undefined` when they leave no room for one. */
380
- function textMarker(column) {
381
- const { min, max } = textLengthBounds(column.checks, column.name);
382
- const [expression, length] = max >= 28 ? ["`isomorph check ${Date.now()}`", 28] : max >= 13 ? ["String(Date.now())", 13] : max >= 6 ? [`String(Date.now()).slice(-${max})`, max] : [undefined, 0];
383
- if (!expression)
384
- return undefined;
385
- return min > length ? `${expression}.padEnd(${min}, "x")` : expression;
386
- }
387
- /** A value for a column a write must supply, or `undefined` when nothing can be invented for its type. */
388
- function fillExpression(column) {
389
- const literals = allowedLiterals(column.checks, column.name);
390
- if (literals.length)
391
- return JSON.stringify(literals[0]);
392
- switch (column.type) {
393
- case "text": {
394
- const { min, max } = textLengthBounds(column.checks, column.name);
395
- return JSON.stringify("isomorph check".slice(0, max).padEnd(Math.min(min, max), "x"));
396
- }
397
- case "uuid": return "randomUUID()";
398
- case "integer": return String(Math.ceil(numericMinimum(column.checks, column.name)));
399
- case "number": return String(numericMinimum(column.checks, column.name));
400
- case "boolean": return "false";
401
- case "timestamptz": return "new Date().toISOString()";
402
- case "date": return "new Date().toISOString().slice(0, 10)";
403
- case "time": return "\"12:00:00\"";
404
- case "json": return "{}";
405
- case "array": return "[]";
406
- default: return undefined;
407
- }
408
- }
409
- async function readChecks(root) {
410
- const directory = kitPaths(root).checks;
411
- const names = (await readdir(directory).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
412
- return Promise.all(names.map(async (name) => {
413
- const text = await readFile(join(directory, name), "utf8").catch(() => "");
414
- return { name, path: `.isomorph/checks/${name}`, text, pristine: isPristine(text) };
415
- }));
416
- }
417
- /** What an adopted check exercises that the app's own code no longer does, in the `flow` gate's terms. */
418
- function staleSurface(text, tables, capabilities) {
419
- for (const match of text.matchAll(/\.from\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/g))
420
- if (!tables.has(match[1]))
421
- return `the \`${match[1]}\` table, which no source file queries`;
422
- for (const capability of capabilitiesCalled(text))
423
- if (!capabilities.has(capability))
424
- return `the \`${capability}\` capability, which no source file uses`;
425
- return undefined;
426
- }
427
- /** `insert, update and delete`, for a generated check's opening line. */
428
- const sentence = (verbs) => verbs.length > 1 ? `${verbs.slice(0, -1).join(", ")} and ${verbs.at(-1)}` : verbs.join("");
429
- /** A check that acts as the runner's second person, in the SDK's form. */
430
- const actsAsSecondUser = (text) => /identity:\s*["'`]second-user["'`]/.test(text);
431
- const coversTableVerb = (text, table, verb) => new RegExp(`\\.from\\(\\s*["'\`]${table}["'\`]\\s*\\)`).test(text) && new RegExp(`\\.${verb}\\s*\\(`).test(text);
432
- /** `notes` gets `notes-journey.mjs`; a table sharing a capability's name does not shadow it. */
433
- const fileName = (table, kind) => `${table}${RESERVED_NAMES.has(table) ? "-table" : ""}-${kind}.mjs`;
434
- // ---- Ownership ------------------------------------------------------------------
435
- const digest = (body) => createHash("sha256").update(body).digest("hex").slice(0, 16);
436
- const sign = (body) => `${GENERATED_PREFIX}${digest(body)}\n${body}`;
437
- /** True only for a file Isomorph wrote and nobody has edited since. `prefix` is only ever an older release's marker, passed by `init --upgrade`. */
438
- export function isPristine(text, prefix = GENERATED_PREFIX) {
439
- if (!text.startsWith(prefix))
440
- return false;
441
- const end = text.indexOf("\n");
442
- return end > 0 && text.slice(0, end).trim() === `${prefix}${digest(text.slice(end + 1))}`;
443
- }