typion 0.0.0-stage → 0.1.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/bin/typion.js +7 -0
  4. package/dist/auth/better-auth.d.ts +13 -0
  5. package/dist/auth/better-auth.js +11 -0
  6. package/dist/auth/better-auth.js.map +1 -0
  7. package/dist/cli/index.d.ts +14 -0
  8. package/dist/cli/index.js +883 -0
  9. package/dist/cli/index.js.map +1 -0
  10. package/dist/column-kinds-Bkm4NJI2.js +47 -0
  11. package/dist/column-kinds-Bkm4NJI2.js.map +1 -0
  12. package/dist/context-B4e2eREP.d.ts +2100 -0
  13. package/dist/contract/index.d.ts +137 -0
  14. package/dist/contract/index.js +42 -0
  15. package/dist/contract/index.js.map +1 -0
  16. package/dist/db/pglite.d.ts +12 -0
  17. package/dist/db/pglite.js +27 -0
  18. package/dist/db/pglite.js.map +1 -0
  19. package/dist/descriptor-Cc3mGp73.js +12 -0
  20. package/dist/descriptor-Cc3mGp73.js.map +1 -0
  21. package/dist/errors-ChDBxeS-.js +17 -0
  22. package/dist/errors-ChDBxeS-.js.map +1 -0
  23. package/dist/errors-D-CKO-px.js +114 -0
  24. package/dist/errors-D-CKO-px.js.map +1 -0
  25. package/dist/handler-BWVU8TCR.d.ts +23 -0
  26. package/dist/handler-_87bFkbP.js +565 -0
  27. package/dist/handler-_87bFkbP.js.map +1 -0
  28. package/dist/hono/index.d.ts +17 -0
  29. package/dist/hono/index.js +26 -0
  30. package/dist/hono/index.js.map +1 -0
  31. package/dist/http/index.d.ts +13 -0
  32. package/dist/http/index.js +2 -0
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +1324 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/issues-DLqCBQT1.js +42 -0
  37. package/dist/issues-DLqCBQT1.js.map +1 -0
  38. package/dist/next/index.d.ts +8 -0
  39. package/dist/next/index.js +18 -0
  40. package/dist/next/index.js.map +1 -0
  41. package/dist/operations-D3rOacLX.js +1811 -0
  42. package/dist/operations-D3rOacLX.js.map +1 -0
  43. package/dist/schema/index.d.ts +18 -0
  44. package/dist/schema/index.js +3 -0
  45. package/dist/schema-Rzzq97cK.js +68 -0
  46. package/dist/schema-Rzzq97cK.js.map +1 -0
  47. package/dist/seed/index.d.ts +19 -0
  48. package/dist/seed/index.js +85 -0
  49. package/dist/seed/index.js.map +1 -0
  50. package/dist/structural-BZnjFmCs.js +247 -0
  51. package/dist/structural-BZnjFmCs.js.map +1 -0
  52. package/dist/testing/index.d.ts +26 -0
  53. package/dist/testing/index.js +92 -0
  54. package/dist/testing/index.js.map +1 -0
  55. package/dist/typion-Dh2BCSRz.js +1187 -0
  56. package/dist/typion-Dh2BCSRz.js.map +1 -0
  57. package/dist/validation/zod.d.ts +21 -0
  58. package/dist/validation/zod.js +84 -0
  59. package/dist/validation/zod.js.map +1 -0
  60. package/docs/README.md +145 -0
  61. package/docs/access.md +114 -0
  62. package/docs/auth.md +195 -0
  63. package/docs/collections.md +172 -0
  64. package/docs/database.md +76 -0
  65. package/docs/features/localization.md +112 -0
  66. package/docs/features/multi-tenant.md +166 -0
  67. package/docs/features/soft-delete.md +85 -0
  68. package/docs/features/versions.md +106 -0
  69. package/docs/hooks.md +106 -0
  70. package/docs/http.md +188 -0
  71. package/docs/queries.md +200 -0
  72. package/docs/testing.md +85 -0
  73. package/package.json +125 -3
@@ -0,0 +1,883 @@
1
+ import { n as ConfigError } from "../errors-D-CKO-px.js";
2
+ import { PgTable, getTableConfig } from "drizzle-orm/pg-core";
3
+ import { is, sql } from "drizzle-orm";
4
+ import { parseArgs } from "node:util";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createInterface } from "node:readline/promises";
10
+ //#region src/cli/output.ts
11
+ var CliError = class extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "CliError";
15
+ }
16
+ };
17
+ const consoleIo = {
18
+ out: (line) => console.log(line),
19
+ err: (line) => console.error(line)
20
+ };
21
+ //#endregion
22
+ //#region src/cli/config.ts
23
+ const NAMES = [
24
+ "typion.config.ts",
25
+ "typion.config.js",
26
+ "typion.config.mts",
27
+ "typion.config.mjs"
28
+ ];
29
+ const DIRS = [
30
+ "",
31
+ "src",
32
+ "app",
33
+ "config"
34
+ ];
35
+ function findConfig(cwd, given) {
36
+ if (given !== void 0) {
37
+ const path = isAbsolute(given) ? given : resolve(cwd, given);
38
+ if (!existsSync(path)) throw new CliError(`No config at ${path}`);
39
+ return path;
40
+ }
41
+ let dir = resolve(cwd);
42
+ for (;;) {
43
+ for (const sub of DIRS) for (const name of NAMES) {
44
+ const candidate = join(dir, sub, name);
45
+ if (existsSync(candidate)) return candidate;
46
+ }
47
+ const parent = dirname(dir);
48
+ if (parent === dir) break;
49
+ dir = parent;
50
+ }
51
+ throw new CliError(`No typion.config.ts found from ${cwd}. Pass --config <path>, or run \`typion init\`.`);
52
+ }
53
+ async function importConfig(path) {
54
+ try {
55
+ return await withoutTypelessWarning(async () => await import(pathToFileURL(path).href));
56
+ } catch (error) {
57
+ const jiti = await loadJiti(path);
58
+ if (jiti === null) throw error;
59
+ return await jiti.import(path, {});
60
+ }
61
+ }
62
+ async function withoutTypelessWarning(load) {
63
+ const emit = process.emitWarning;
64
+ process.emitWarning = ((warning, ...rest) => {
65
+ if ((rest[0]?.code ?? rest[1]) === "MODULE_TYPELESS_PACKAGE_JSON") return;
66
+ emit.call(process, warning, ...rest);
67
+ });
68
+ try {
69
+ return await load();
70
+ } finally {
71
+ process.emitWarning = emit;
72
+ }
73
+ }
74
+ async function loadJiti(path) {
75
+ try {
76
+ return (await import("jiti")).createJiti(pathToFileURL(path).href, { interopDefault: true });
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+ function pickConfig(exports, path) {
82
+ const candidates = [
83
+ exports.config,
84
+ exports.default,
85
+ ...Object.values(exports)
86
+ ];
87
+ for (const value of candidates) if (typeof value === "object" && value !== null && "definition" in value && "schema" in value && typeof value.definition === "object") return value;
88
+ throw new CliError(`${path} exports no Typion config (expected \`export const config = defineConfig(…)\`)`);
89
+ }
90
+ function loadEnv(dir) {
91
+ for (const name of [".env.local", ".env"]) {
92
+ const path = join(dir, name);
93
+ if (existsSync(path)) process.loadEnvFile(path);
94
+ }
95
+ }
96
+ async function loadConfig(cwd, given) {
97
+ loadEnv(cwd);
98
+ const path = findConfig(cwd, given);
99
+ return {
100
+ config: pickConfig(await importConfig(path), path),
101
+ path,
102
+ dir: dirname(path)
103
+ };
104
+ }
105
+ //#endregion
106
+ //#region src/cli/db.ts
107
+ const BREAKPOINT = "--> statement-breakpoint";
108
+ const DEFAULT_DIR = "typion/migrations";
109
+ const DEFAULT_TABLE = "typion_migrations";
110
+ async function api() {
111
+ try {
112
+ return await import("drizzle-kit/api-postgres");
113
+ } catch {
114
+ throw new CliError("The database commands need drizzle-kit installed (`pnpm add -D drizzle-kit`)");
115
+ }
116
+ }
117
+ function schemaOf(loaded) {
118
+ return loaded.config.schema;
119
+ }
120
+ function projectRoot(loaded) {
121
+ let dir = loaded.dir;
122
+ for (;;) {
123
+ if (existsSync(join(dir, "package.json"))) return dir;
124
+ const parent = dirname(dir);
125
+ if (parent === dir) return loaded.dir;
126
+ dir = parent;
127
+ }
128
+ }
129
+ function migrationsDir(loaded) {
130
+ const dir = loaded.config.migrations?.dir ?? DEFAULT_DIR;
131
+ return isAbsolute(dir) ? dir : resolve(projectRoot(loaded), dir);
132
+ }
133
+ function migrationsTable(loaded) {
134
+ return {
135
+ schema: loaded.config.migrations?.schema ?? "public",
136
+ table: loaded.config.migrations?.table ?? DEFAULT_TABLE
137
+ };
138
+ }
139
+ function readJournal(dir) {
140
+ try {
141
+ return JSON.parse(readFileSync(join(dir, "meta", "journal.json"), "utf8"));
142
+ } catch {
143
+ return {
144
+ version: 1,
145
+ entries: []
146
+ };
147
+ }
148
+ }
149
+ function writeJournal(dir, journal) {
150
+ mkdirSync(join(dir, "meta"), { recursive: true });
151
+ writeFileSync(join(dir, "meta", "journal.json"), `${JSON.stringify(journal, null, 2)}\n`);
152
+ }
153
+ function connect(loaded) {
154
+ const adapter = loaded.config.db;
155
+ if (adapter === void 0) throw new CliError("The config has no database adapter (`db` in defineConfig)");
156
+ adapter.check?.();
157
+ return adapter.connect(loaded.config.definition.relations);
158
+ }
159
+ async function push(loaded, io, options) {
160
+ const { pushSchema } = await api();
161
+ const db = connect(loaded);
162
+ const { sqlStatements, hints, apply } = await pushSchema(schemaOf(loaded), db);
163
+ if (sqlStatements.length === 0) {
164
+ io.out("The database already matches the config.");
165
+ return;
166
+ }
167
+ for (const statement of sqlStatements) io.out(statement);
168
+ if (hints.length > 0) {
169
+ for (const hint of hints) io.err(`! ${hint.hint}`);
170
+ if (options.force !== true) throw new CliError("These changes can lose data: run again with --force to apply them.");
171
+ }
172
+ await apply();
173
+ io.out(`Applied ${sqlStatements.length} statement(s).`);
174
+ await loaded.config.db?.close?.();
175
+ }
176
+ async function generate(loaded, io, options) {
177
+ const { generateDrizzleJson, generateMigration } = await api();
178
+ const dir = migrationsDir(loaded);
179
+ const journal = readJournal(dir);
180
+ const last = journal.entries.at(-1);
181
+ const previous = last === void 0 ? await generateDrizzleJson({}) : JSON.parse(readFileSync(join(dir, "meta", `${String(last.idx).padStart(4, "0")}_snapshot.json`), "utf8"));
182
+ const current = await generateDrizzleJson(schemaOf(loaded), previous.id);
183
+ const statements = await generateMigration(previous, current);
184
+ if (statements.length === 0) {
185
+ io.out("No changes: the migrations already describe the config.");
186
+ return;
187
+ }
188
+ const idx = (last?.idx ?? -1) + 1;
189
+ const tag = `${String(idx).padStart(4, "0")}_${(options.name ?? "change").replace(/[^a-z0-9_-]+/gi, "_")}`;
190
+ mkdirSync(join(dir, "meta"), { recursive: true });
191
+ writeFileSync(join(dir, `${tag}.sql`), `${statements.join(`\n${BREAKPOINT}\n`)}\n`);
192
+ writeFileSync(join(dir, "meta", `${String(idx).padStart(4, "0")}_snapshot.json`), `${JSON.stringify(current, null, 2)}\n`);
193
+ journal.entries.push({
194
+ idx,
195
+ tag,
196
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
197
+ });
198
+ writeJournal(dir, journal);
199
+ io.out(`Wrote ${tag}.sql with ${statements.length} statement(s).`);
200
+ }
201
+ async function applied(db, where) {
202
+ if (where.schema !== "public") await db.execute(sql.raw(`create schema if not exists "${where.schema}"`));
203
+ await db.execute(sql.raw(`create table if not exists "${where.schema}"."${where.table}" (tag text primary key, applied_at timestamptz not null default now())`));
204
+ const rows = await db.execute(sql.raw(`select tag from "${where.schema}"."${where.table}"`));
205
+ const list = rows.rows ?? rows;
206
+ return new Set(list.map((row) => row.tag));
207
+ }
208
+ async function migrate(loaded, io) {
209
+ const dir = migrationsDir(loaded);
210
+ const journal = readJournal(dir);
211
+ if (journal.entries.length === 0) {
212
+ io.out("No migrations to apply: run `typion db generate` first.");
213
+ return;
214
+ }
215
+ const db = connect(loaded);
216
+ const where = migrationsTable(loaded);
217
+ const done = await applied(db, where);
218
+ const pending = journal.entries.filter((entry) => !done.has(entry.tag));
219
+ if (pending.length === 0) {
220
+ io.out("The database is up to date.");
221
+ await loaded.config.db?.close?.();
222
+ return;
223
+ }
224
+ for (const entry of pending) {
225
+ const statements = readFileSync(join(dir, `${entry.tag}.sql`), "utf8").split(BREAKPOINT).map((statement) => statement.trim()).filter((statement) => statement !== "");
226
+ await db.transaction(async (tx) => {
227
+ for (const statement of statements) await tx.execute(sql.raw(statement));
228
+ await tx.execute(sql.raw(`insert into "${where.schema}"."${where.table}" (tag) values ('${entry.tag}')`));
229
+ });
230
+ io.out(`Applied ${entry.tag} (${statements.length} statement(s)).`);
231
+ }
232
+ await loaded.config.db?.close?.();
233
+ }
234
+ const SEED_FILES = [
235
+ "typion/seed.ts",
236
+ "typion/seed.js",
237
+ "seed.ts",
238
+ "seed.js"
239
+ ];
240
+ function findSeedFile(loaded, given) {
241
+ if (given !== void 0) {
242
+ const path = isAbsolute(given) ? given : resolve(process.cwd(), given);
243
+ if (!existsSync(path)) throw new CliError(`No seed file at ${path}`);
244
+ return path;
245
+ }
246
+ for (const dir of [projectRoot(loaded), loaded.dir]) for (const name of SEED_FILES) {
247
+ const candidate = join(dir, name);
248
+ if (existsSync(candidate)) return candidate;
249
+ }
250
+ return null;
251
+ }
252
+ async function anyRows(loaded) {
253
+ const db = connect(loaded);
254
+ for (const entity of Object.values(loaded.config.definition.entities)) {
255
+ if (entity.owner !== "user") continue;
256
+ const where = `"${entity.pgSchema ?? "public"}"."${entity.tableName}"`;
257
+ const rows = await db.execute(sql.raw(`select 1 from ${where} limit 1`));
258
+ if ((rows.rows ?? rows).length > 0) return true;
259
+ }
260
+ return false;
261
+ }
262
+ async function seed(loaded, io, options) {
263
+ if (options.ifEmpty === true && await anyRows(loaded)) {
264
+ io.out("The collections already hold rows: nothing seeded.");
265
+ await loaded.config.db?.close?.();
266
+ return;
267
+ }
268
+ const { resetTables, seedCollections } = await import("../seed/index.js");
269
+ if (options.reset === true) {
270
+ await resetTables(loaded.config);
271
+ io.out("Emptied every table of the config.");
272
+ }
273
+ if (options.random === true) {
274
+ await seedCollections(loaded.config, {
275
+ count: options.count,
276
+ seed: options.seed
277
+ });
278
+ io.out(`Seeded every collection with ${options.count ?? 10} rows.`);
279
+ await loaded.config.db?.close?.();
280
+ return;
281
+ }
282
+ const file = findSeedFile(loaded, options.file);
283
+ if (file === null) throw new CliError("No seed file found (typion/seed.ts). Write one, point at it with --file, or use --random.");
284
+ const { getTypion } = await import("../typion-Dh2BCSRz.js").then((n) => n.i);
285
+ const typion = await getTypion(loaded.config);
286
+ const seedModule = await import(pathToFileURL(file).href);
287
+ const run = seedModule.seed ?? seedModule.default;
288
+ if (typeof run !== "function") throw new CliError(`${file} exports no seed function (\`export async function seed({ typion })\`)`);
289
+ await run({
290
+ typion,
291
+ config: loaded.config
292
+ });
293
+ io.out(`Ran ${file}.`);
294
+ await loaded.config.db?.close?.();
295
+ }
296
+ async function status(loaded, io) {
297
+ const journal = readJournal(migrationsDir(loaded));
298
+ const done = await applied(connect(loaded), migrationsTable(loaded));
299
+ if (journal.entries.length === 0) io.out("No migrations yet.");
300
+ for (const entry of journal.entries) io.out(`${done.has(entry.tag) ? "applied" : "pending"} ${entry.tag}`);
301
+ const tables = Object.values(schemaOf(loaded)).filter((value) => is(value, PgTable));
302
+ io.out(`${tables.length} table(s) in the config: ${tables.map((t) => getTableConfig(t).name).join(", ")}`);
303
+ await loaded.config.db?.close?.();
304
+ }
305
+ async function studio(loaded, io, options) {
306
+ const { startStudioServer } = await api();
307
+ const credentials = loaded.config.db?.credentials?.();
308
+ if (credentials === void 0) throw new CliError("This database adapter cannot open Studio: pass `url` to dbAdapter(), or use typion/db/pglite.");
309
+ io.out(`Studio on https://local.drizzle.studio (port ${options.port ?? 4983})`);
310
+ await startStudioServer(schemaOf(loaded), credentials, { port: options.port });
311
+ }
312
+ //#endregion
313
+ //#region src/cli/agents.ts
314
+ const START_MARKER = "<!-- BEGIN:typion-agent-rules -->";
315
+ const END_MARKER = "<!-- END:typion-agent-rules -->";
316
+ const AGENT_RULES = `${START_MARKER}
317
+
318
+ # Typion
319
+
320
+ This application's data layer is Typion, which is newer than your training data. Before you add or change a
321
+ collection, an access rule, a hook, a query or an API route, read \`node_modules/typion/docs/README.md\` (resolved
322
+ from this file's directory) and the page it points to for the task.
323
+
324
+ - Read and write the application's data through Typion, bound to the caller: \`getApp(config)\` on Next.js,
325
+ \`c.var.typion\` on Hono, \`typion.$fromRequest(request)\` elsewhere, \`typion.$as(ctx)\`. Never through
326
+ \`typion.$db\`, Drizzle or SQL: they skip the access rules, tenants, soft delete, drafts, hooks and the history.
327
+ - \`typion.$system({ reason })\` skips the access rules: for seeds, jobs and webhooks, never to answer a user's
328
+ request.
329
+ - Authorization lives in each collection's \`access\` rules, deny by default, not in pages, routes or server
330
+ actions.
331
+ - Every schema change ships a migration: \`typion db generate --name <change>\`.
332
+
333
+ This block is written by \`typion init\`; run it again after upgrading Typion.
334
+
335
+ ${END_MARKER}`;
336
+ const SKILL_MARKER = "<!-- Written by `typion init`, which rewrites it: keep project notes in AGENTS.md. -->";
337
+ const SKILL = `---
338
+ name: typion
339
+ description: Use for any change to this application's data layer with Typion — collections, access rules, hooks, queries and descriptors, API routes, seeds, migrations, or tests that touch typion.config.ts or the typion/ folder next to it.
340
+ ---
341
+
342
+ ${SKILL_MARKER}
343
+
344
+ Typion is newer than your training data. Its guide ships with the installed version, in \`node_modules/typion/docs/\`.
345
+
346
+ 1. Read \`node_modules/typion/docs/README.md\`: the model, the rules, where things go.
347
+ 2. Read the page for the task:
348
+ - \`collections.md\`: a collection, its columns and relations, a many-to-many
349
+ - \`access.md\`: who reads and writes what, hidden columns, what a caller sees
350
+ - \`auth.md\`: signing in, who the caller is
351
+ - \`hooks.md\`: reacting to writes
352
+ - \`queries.md\`: reads, writes, descriptors, transactions, errors
353
+ - \`http.md\`: Next.js pages and server actions, Hono, the routes, the browser client, business operations
354
+ - \`database.md\`: drivers, migrations, seed
355
+ - \`testing.md\`: tests
356
+ - \`features/\`: localization, multi-tenancy, soft delete, versions and drafts
357
+ 3. Before you call the change done: \`tsc\` passes, every new collection has its rules, a test covers a caller the
358
+ rules allow and one they refuse, and a schema change has its migration.
359
+ `;
360
+ function upsert(existing) {
361
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
362
+ const block = AGENT_RULES.replace(/\n/g, eol);
363
+ const start = existing.indexOf(START_MARKER);
364
+ const end = existing.indexOf(END_MARKER, start);
365
+ if (start !== -1 && end !== -1) return existing.slice(0, start) + block + existing.slice(end + 31);
366
+ if (existing.length === 0) return `${block}${eol}`;
367
+ return `${existing.endsWith(eol) ? existing : `${existing}${eol}`}${eol}${block}${eol}`;
368
+ }
369
+ function writeFile(path, next, previous) {
370
+ if (previous === next) return "unchanged";
371
+ mkdirSync(dirname(path), { recursive: true });
372
+ writeFileSync(path, next);
373
+ return previous === null ? "created" : "updated";
374
+ }
375
+ function read(path) {
376
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
377
+ }
378
+ function writeAgentNotes(root, io) {
379
+ const agentsPath = join(root, "AGENTS.md");
380
+ const claudePath = join(root, "CLAUDE.md");
381
+ const agents = read(agentsPath);
382
+ const claude = read(claudePath);
383
+ const report = (path, outcome) => {
384
+ const verb = {
385
+ created: "Created",
386
+ updated: "Updated",
387
+ kept: "Kept",
388
+ unchanged: null
389
+ }[outcome];
390
+ if (verb !== null) io.out(`${verb} ${relative(process.cwd(), path)}.`);
391
+ };
392
+ const claudeHostsBlock = claude?.includes("<!-- BEGIN:typion-agent-rules -->") ?? false;
393
+ if (agents !== null && (agents.includes("<!-- BEGIN:typion-agent-rules -->") || !claudeHostsBlock)) report(agentsPath, writeFile(agentsPath, upsert(agents), agents));
394
+ else if (claude !== null) report(claudePath, writeFile(claudePath, upsert(claude), claude));
395
+ else {
396
+ report(agentsPath, writeFile(agentsPath, upsert(""), null));
397
+ report(claudePath, writeFile(claudePath, "@AGENTS.md\n", null));
398
+ }
399
+ const skillPath = join(root, ".claude", "skills", "typion", "SKILL.md");
400
+ const skill = read(skillPath);
401
+ if (skill !== null && !skill.includes(SKILL_MARKER)) io.out(`Kept ${relative(process.cwd(), skillPath)} (it is not Typion's any more).`);
402
+ else report(skillPath, writeFile(skillPath, SKILL, skill));
403
+ }
404
+ //#endregion
405
+ //#region src/cli/init.ts
406
+ const DRIZZLE = "1.0.0-rc.4";
407
+ const TRANSPORTS = ["@typion/rest", "@typion/rpc"];
408
+ function packageManager(root) {
409
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
410
+ if (existsSync(join(root, "yarn.lock"))) return "yarn";
411
+ if (existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))) return "bun";
412
+ if (existsSync(join(root, "package-lock.json"))) return "npm";
413
+ const agent = process.env.npm_config_user_agent ?? "";
414
+ if (agent.startsWith("yarn")) return "yarn";
415
+ if (agent.startsWith("bun")) return "bun";
416
+ if (agent.startsWith("pnpm")) return "pnpm";
417
+ return "npm";
418
+ }
419
+ function installCommand(manager, packages, dev) {
420
+ const flag = dev ? manager === "npm" ? "--save-dev" : "-D" : "";
421
+ return {
422
+ command: manager,
423
+ args: [
424
+ manager === "npm" ? "install" : "add",
425
+ ...flag === "" ? [] : [flag],
426
+ ...packages
427
+ ]
428
+ };
429
+ }
430
+ async function confirm(question, io) {
431
+ if (!process.stdin.isTTY) {
432
+ io.out(`${question} — not a terminal, so nothing was installed. Re-run with --yes.`);
433
+ return false;
434
+ }
435
+ const rl = createInterface({
436
+ input: process.stdin,
437
+ output: process.stdout
438
+ });
439
+ try {
440
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
441
+ return answer === "" || answer === "y" || answer === "yes";
442
+ } finally {
443
+ rl.close();
444
+ }
445
+ }
446
+ function run$1(command, args, cwd, io) {
447
+ io.out(`> ${command} ${args.join(" ")}`);
448
+ const result = spawnSync(command, [...args], {
449
+ cwd,
450
+ stdio: "inherit"
451
+ });
452
+ if (result.error !== void 0 || result.status !== null && result.status !== 0) throw new CliError(`\`${command} ${args.join(" ")}\` failed; install them yourself and re-run.`);
453
+ }
454
+ function readJson(path) {
455
+ try {
456
+ return JSON.parse(readFileSync(path, "utf8"));
457
+ } catch {
458
+ return null;
459
+ }
460
+ }
461
+ function inspect(cwd) {
462
+ const pkg = readJson(join(cwd, "package.json"));
463
+ if (pkg === null) throw new CliError(`No package.json in ${cwd}: run this inside your project.`);
464
+ const deps = {
465
+ ...pkg.dependencies,
466
+ ...pkg.devDependencies
467
+ };
468
+ const src = existsSync(join(cwd, "src")) ? join(cwd, "src") : cwd;
469
+ const appDir = [join(src, "app"), join(cwd, "app")].find((dir) => existsSync(dir)) ?? null;
470
+ return {
471
+ root: cwd,
472
+ src,
473
+ framework: deps.next !== void 0 ? "next" : deps.hono !== void 0 ? "hono" : "none",
474
+ dependencies: new Set(Object.keys(deps)),
475
+ appDir
476
+ };
477
+ }
478
+ function write(path, contents, io) {
479
+ if (existsSync(path)) {
480
+ io.out(`Kept ${relative(process.cwd(), path)} (it exists already).`);
481
+ return false;
482
+ }
483
+ mkdirSync(dirname(path), { recursive: true });
484
+ writeFileSync(path, contents);
485
+ io.out(`Created ${relative(process.cwd(), path)}.`);
486
+ return true;
487
+ }
488
+ const COLLECTION = `import { text } from 'drizzle-orm/pg-core'
489
+ import { collection } from 'typion'
490
+
491
+ export const pages = collection('pages', {
492
+ columns: {
493
+ title: text().notNull(),
494
+ slug: text().notNull().unique(),
495
+ },
496
+ public: true,
497
+ })
498
+ `;
499
+ function configFile(driver) {
500
+ return `${driver === "pglite" ? `import { defineConfig, defineSetup } from 'typion'\nimport { pglite } from 'typion/db/pglite'` : `import { drizzle } from 'drizzle-orm/node-postgres'\nimport { dbAdapter, defineConfig, defineSetup } from 'typion'`}
501
+ import { pages } from './typion/collections/pages.ts'
502
+
503
+ // What the application knows about a caller: rules, hooks and field rules read it. Registered below as well, since
504
+ // field rules are typed before this config exists.
505
+ export type AppContext = {
506
+ actorId: string | null
507
+ }
508
+
509
+ // A constant of its own: the collections read it to type their options and their columns, which happens before
510
+ // this config exists. It takes the features (\`localization\`, \`multiTenant\`) and the plugins (\`plugins: […]\`).
511
+ export const setup = defineSetup({})
512
+
513
+ export const config = defineConfig({
514
+ setup,
515
+ collections: [pages],
516
+ ${driver === "pglite" ? ` db: pglite({ dataDir: '.pglite' }),` : ` db: dbAdapter((relations) => drizzle(process.env.DATABASE_URL!, { relations }), {\n url: process.env.DATABASE_URL,\n }),`}
517
+ // Who is calling. Return your own shape; declare the return type, the body may query \`system\`.
518
+ context: async ({ headers }): Promise<AppContext> => {
519
+ void headers
520
+ return { actorId: null }
521
+ },
522
+ })
523
+
524
+ declare module 'typion' {
525
+ interface Register {
526
+ setup: typeof setup
527
+ config: typeof config
528
+ context: AppContext
529
+ }
530
+ }
531
+ `;
532
+ }
533
+ function allowTsImports(root, io) {
534
+ const path = join(root, "tsconfig.json");
535
+ if (!existsSync(path)) return;
536
+ const json = readJson(path);
537
+ const options = json?.compilerOptions ?? {};
538
+ if (options.allowImportingTsExtensions === true || options.rewriteRelativeImportExtensions === true) return;
539
+ const flag = options.noEmit === true ? "allowImportingTsExtensions" : "rewriteRelativeImportExtensions";
540
+ if (json === null) {
541
+ io.out(`Add "${flag}": true to the compilerOptions of tsconfig.json: Typion's imports end in .ts.`);
542
+ return;
543
+ }
544
+ json.compilerOptions = {
545
+ ...options,
546
+ [flag]: true
547
+ };
548
+ writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`);
549
+ io.out(`Set ${flag} in tsconfig.json: Typion's imports end in .ts.`);
550
+ }
551
+ function pnpmMajor(root) {
552
+ const declared = /^pnpm@(\d+)/.exec(String(readJson(join(root, "package.json"))?.packageManager));
553
+ const agent = /^pnpm\/(\d+)/.exec(process.env.npm_config_user_agent ?? "");
554
+ const version = declared?.[1] ?? agent?.[1] ?? /^(\d+)\./.exec(spawnSync("pnpm", ["--version"], {
555
+ cwd: root,
556
+ encoding: "utf8"
557
+ }).stdout ?? "")?.[1];
558
+ return version === void 0 ? null : Number(version);
559
+ }
560
+ function workspaceFile(root) {
561
+ for (let dir = root; dirname(dir) !== dir; dir = dirname(dir)) if (existsSync(join(dir, "pnpm-workspace.yaml"))) return join(dir, "pnpm-workspace.yaml");
562
+ return join(root, "pnpm-workspace.yaml");
563
+ }
564
+ function allowEsbuild(root, io) {
565
+ const major = pnpmMajor(root);
566
+ if (major === null || major < 11) return;
567
+ const path = workspaceFile(root);
568
+ const name = relative(process.cwd(), path);
569
+ const why = `pnpm ${major} fails an install on a build script it was not told to run, and drizzle-kit brings esbuild's`;
570
+ if (!existsSync(path)) {
571
+ writeFileSync(path, "allowBuilds:\n esbuild: true\n");
572
+ io.out(`Created ${name} to allow esbuild's build script: ${why}.`);
573
+ return;
574
+ }
575
+ const text = readFileSync(path, "utf8");
576
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
577
+ const lines = text.split(/\r?\n/);
578
+ const valueOf = (line) => line.slice(line.indexOf(":") + 1).replace(/(^|\s)#.*$/, "").trim();
579
+ const key = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
580
+ if (key === -1) {
581
+ if (lines.at(-1) === "") lines.pop();
582
+ lines.push(...lines.length === 0 ? [] : [""], "allowBuilds:", " esbuild: true", "");
583
+ } else if (valueOf(lines[key]) !== "") {
584
+ if (!/\besbuild\b/.test(lines[key])) io.out(`Add esbuild: true to allowBuilds in ${name}: ${why}.`);
585
+ return;
586
+ } else {
587
+ let end = key + 1;
588
+ while (end < lines.length && /^(\s|#|$)/.test(lines[end])) end++;
589
+ const block = lines.slice(key + 1, end);
590
+ const entry = block.findIndex((line) => /^\s+(['"]?)esbuild\1\s*:/.test(line));
591
+ if (entry === -1) {
592
+ const indent = block.map((line) => /^(\s+)[^\s#]/.exec(line)?.[1]).find(Boolean) ?? " ";
593
+ lines.splice(key + 1, 0, `${indent}esbuild: true`);
594
+ } else {
595
+ if (/^(true|false)$/i.test(valueOf(block[entry]))) return;
596
+ lines[key + 1 + entry] = `${/^\s+/.exec(block[entry])[0]}esbuild: true`;
597
+ }
598
+ }
599
+ writeFileSync(path, lines.join(eol));
600
+ io.out(`Set allowBuilds: esbuild: true in ${name}: ${why}.`);
601
+ }
602
+ const ROUTE = `import { rest } from '@typion/rest'
603
+ import { rpc } from '@typion/rpc'
604
+ import { routeHandlers } from 'typion/next'
605
+ import { config } from '@/typion.config.ts'
606
+
607
+ // What the app serves over HTTP: a resource per collection, and the endpoint the browser client posts to. Leave
608
+ // out what you do not use.
609
+ export const { GET, POST, PATCH, PUT, DELETE, OPTIONS } = await routeHandlers(config, {
610
+ transports: [rest(), rpc()],
611
+ })
612
+ `;
613
+ async function init(cwd, io, options) {
614
+ const project = inspect(cwd);
615
+ const driver = options.driver === "postgres" ? "postgres" : "pglite";
616
+ if (write(join(project.src, "typion.config.ts"), configFile(driver), io)) write(join(project.src, "typion", "collections", "pages.ts"), COLLECTION, io);
617
+ allowTsImports(project.root, io);
618
+ if (project.framework === "next" && project.appDir !== null) write(join(project.appDir, "api", "[...path]", "route.ts"), ROUTE, io);
619
+ writeAgentNotes(project.root, io);
620
+ const manager = packageManager(project.root);
621
+ if (manager === "pnpm") allowEsbuild(project.root, io);
622
+ await installDependencies(project, manager, driver, io, options);
623
+ io.out("");
624
+ io.out("Next:");
625
+ io.out(" 1. turn on what you need: `softDelete` or `versions` on a collection, `localization`, `multiTenant` or `plugins` in `defineSetup`");
626
+ io.out(existsSync(join(project.root, "typion", "migrations")) ? " 2. `typion db migrate` to apply the migrations, then `typion db seed`" : " 2. `typion db push` to create the tables, then `typion db seed`");
627
+ if (project.framework === "next") io.out(" 3. read from a server component with `const app = await getApp(config)` (typion/next)");
628
+ if (project.framework === "hono") {
629
+ io.out(" 3. bind the caller in your own routes with `typionMiddleware(config)`: `c.var.typion`");
630
+ const transports = TRANSPORTS.filter((name) => !project.dependencies.has(name));
631
+ const add = installCommand(manager, transports, false);
632
+ const install = transports.length === 0 ? "" : `\`${add.command} ${add.args.join(" ")}\`, then `;
633
+ io.out(` 4. to serve the collections over HTTP: ${install}\`.all('/api/*', typionRoutes(config, { transports: [rest(), rpc()] }))\` (typion/hono)`);
634
+ }
635
+ }
636
+ async function installDependencies(project, manager, driver, io, options) {
637
+ const deps = [
638
+ "typion",
639
+ "drizzle-orm",
640
+ driver === "pglite" ? "@electric-sql/pglite" : "pg",
641
+ ...project.framework === "next" ? TRANSPORTS : []
642
+ ];
643
+ const dev = ["drizzle-kit"];
644
+ const spec = (name) => name.startsWith("drizzle-") ? `${name}@${DRIZZLE}` : name;
645
+ const missing = deps.filter((name) => !project.dependencies.has(name)).map(spec);
646
+ const missingDev = dev.filter((name) => !project.dependencies.has(name)).map(spec);
647
+ if (missing.length === 0 && missingDev.length === 0) return;
648
+ const lines = [...missing.length === 0 ? [] : [installCommand(manager, missing, false)], ...missingDev.length === 0 ? [] : [installCommand(manager, missingDev, true)]];
649
+ io.out("");
650
+ io.out("Missing packages:");
651
+ for (const line of lines) io.out(` ${line.command} ${line.args.join(" ")}`);
652
+ if (options.skipInstall === true) return;
653
+ if (!(options.yes === true || await confirm(`Install them with ${manager}?`, io))) return;
654
+ for (const line of lines) run$1(line.command, line.args, project.root, io);
655
+ }
656
+ //#endregion
657
+ //#region src/cli/make.ts
658
+ function pascal(name) {
659
+ return name.replace(/(^|[-_ ])(\w)/g, (_, __, char) => char.toUpperCase());
660
+ }
661
+ function camel(name) {
662
+ const upper = pascal(name);
663
+ return upper.charAt(0).toLowerCase() + upper.slice(1);
664
+ }
665
+ function collectionsDir(loaded, given) {
666
+ if (given !== void 0) return isAbsolute(given) ? given : resolve(process.cwd(), given);
667
+ const candidates = [join(loaded.dir, "typion", "collections"), join(loaded.dir, "collections")];
668
+ return candidates.find((dir) => existsSync(dir)) ?? candidates[0];
669
+ }
670
+ function template(name, tenant) {
671
+ return `import { text } from 'drizzle-orm/pg-core'
672
+ import { collection } from 'typion'
673
+
674
+ export const ${camel(name)} = collection('${camel(name)}', {
675
+ columns: {
676
+ // Plain Drizzle columns; the features and plugins add their own options next to \`columns\`.
677
+ title: text().notNull(),
678
+ },${tenant ? "\n tenant: true," : ""}
679
+ }).define({
680
+ // Deny by default: an operation without a rule is refused. Give each one the rule it needs, for example
681
+ // \`read: ({ ctx }) => ctx.actorId !== null\`.
682
+ access: {},
683
+ })
684
+ `;
685
+ }
686
+ function importPath(from, to) {
687
+ const path = relative(from, to).replaceAll("\\", "/");
688
+ return path.startsWith(".") ? path : `./${path}`;
689
+ }
690
+ function register(loaded, name, file, io) {
691
+ const source = readFileSync(loaded.path, "utf8");
692
+ const specifier = importPath(loaded.dir, file).replace(/\.ts$/, ".ts");
693
+ const line = `import { ${camel(name)} } from '${specifier}'`;
694
+ const list = /collections:\s*\[([^\]]*)\]/.exec(source);
695
+ if (source.includes(line) || list === null) {
696
+ io.out("Add it to the config:");
697
+ io.out(` ${line}`);
698
+ io.out(` collections: [ …, ${camel(name)} ]`);
699
+ return;
700
+ }
701
+ const entries = list[1].trim();
702
+ const updated = source.replace(/(^import .*\n)(?![\s\S]*^import )/m, `$1${line}\n`).replace(/collections:\s*\[([^\]]*)\]/, `collections: [${entries === "" ? "" : `${entries.replace(/,\s*$/, "")}, `}${camel(name)}]`);
703
+ writeFileSync(loaded.path, updated);
704
+ io.out(`Registered it in ${relative(process.cwd(), loaded.path)}.`);
705
+ }
706
+ function pluginTemplate(name) {
707
+ const id = camel(name);
708
+ const Kinds = `${pascal(name)}Kinds`;
709
+ const Options = `${pascal(name)}Options`;
710
+ return `import { definePlugin } from 'typion'
711
+ import type { CollectionKinds } from 'typion'
712
+
713
+ // What this plugin adds to every collection. A collection that does not set the option gets nothing.
714
+ export type ${Options} = { ${id}?: boolean }
715
+
716
+ // How the option turns into columns and into engine options, read from \`this['options']\`.
717
+ interface ${Kinds} extends CollectionKinds {
718
+ readonly columns: this['options'] extends { ${id}: true } ? {} : {}
719
+ readonly entity: this['options'] extends { ${id}: true } ? {} : {}
720
+ }
721
+
722
+ export function ${id}() {
723
+ return definePlugin({
724
+ id: '${id}',
725
+ collection: {
726
+ $options: {} as ${Options},
727
+ $kinds: {} as ${Kinds},
728
+ // Option keys this plugin reads, so they never reach the engine as entity options.
729
+ consumes: ['${id}'],
730
+ columns: (options) => (options.${id} === true ? {} : {}),
731
+ entity: (options) => (options.${id} === true ? {} : {}),
732
+ },
733
+ // Tables, relations, entities, hooks, api, routes, errors and context go here too; see @typion/audit.
734
+ })
735
+ }
736
+ `;
737
+ }
738
+ function makePlugin(loaded, io, name, options) {
739
+ if (!/^[a-z][a-z0-9_-]*$/i.test(name)) throw new CliError(`'${name}' is not a plugin name (letters, digits, dash, underscore)`);
740
+ const dir = options.dir === void 0 ? join(loaded.dir, "typion", "plugins") : isAbsolute(options.dir) ? options.dir : resolve(process.cwd(), options.dir);
741
+ const file = join(dir, `${camel(name)}.ts`);
742
+ if (existsSync(file)) throw new CliError(`${relative(process.cwd(), file)} already exists`);
743
+ mkdirSync(dir, { recursive: true });
744
+ writeFileSync(file, pluginTemplate(name));
745
+ io.out(`Created ${relative(process.cwd(), file)}.`);
746
+ io.out("Install it in the config:");
747
+ io.out(` import { ${camel(name)} } from './${relative(loaded.dir, file).replaceAll("\\", "/")}'`);
748
+ io.out(` export const setup = defineSetup({ plugins: [ …, ${camel(name)}() ] })`);
749
+ }
750
+ function makeCollection(loaded, io, name, options) {
751
+ if (!/^[a-z][a-z0-9_-]*$/i.test(name)) throw new CliError(`'${name}' is not a collection name (letters, digits, dash, underscore)`);
752
+ const dir = collectionsDir(loaded, options.dir);
753
+ const file = join(dir, `${camel(name)}.ts`);
754
+ if (existsSync(file)) throw new CliError(`${relative(process.cwd(), file)} already exists`);
755
+ mkdirSync(dirname(file), { recursive: true });
756
+ const tenant = loaded.config.definition.plugins.some((plugin) => plugin.id === "multiTenant");
757
+ writeFileSync(file, template(name, tenant));
758
+ io.out(`Created ${relative(process.cwd(), file)}.`);
759
+ register(loaded, name, file, io);
760
+ io.out("Then `typion db push` (or `typion db generate` + `typion db migrate`).");
761
+ }
762
+ //#endregion
763
+ //#region src/cli/index.ts
764
+ const HELP = `typion — the CLI of a Typion project
765
+
766
+ Usage
767
+ typion <group> <command> [options]
768
+
769
+ Database (everything that touches the schema, through drizzle-kit)
770
+ typion db push [--force] make the database match the config (development)
771
+ typion db generate [--name x] write the SQL of what changed, with its snapshot
772
+ typion db migrate apply the migrations the database has not applied
773
+ typion db seed [--file path] run the project's seed through the engine
774
+ [--random] ask drizzle-seed for rows instead (--count, --seed)
775
+ [--reset] empty every table of the config first
776
+ [--if-empty] do nothing when a collection already holds rows
777
+ typion db status what is applied, what is pending
778
+ typion db studio [--port n] open Drizzle Studio on this config
779
+
780
+ Project
781
+ typion make collection <name> [--dir path] a new collection, registered in the config
782
+ typion make plugin <name> [--dir path] a plugin skeleton for this project
783
+ typion init [--driver pglite|postgres] [--yes] [--skip-install]
784
+ set Typion up in a project that exists, with the
785
+ notes its agents read (run it again after upgrading)
786
+
787
+ Options
788
+ --config <path> the config to read; by default the nearest typion.config.ts
789
+ --help this text (also: typion db --help)
790
+ `;
791
+ const DB_HELP = `typion db — the database of a Typion config
792
+
793
+ push [--force] make the database match the config (development)
794
+ generate [--name x] write the SQL of what changed, with its snapshot
795
+ migrate apply the migrations the database has not applied
796
+ seed [--file|--random] fill the database: the project's seed, or drizzle-seed rows
797
+ status what is applied, what is pending
798
+ studio [--port n] open Drizzle Studio on this config
799
+ `;
800
+ const OPTIONS = {
801
+ config: { type: "string" },
802
+ name: { type: "string" },
803
+ dir: { type: "string" },
804
+ driver: { type: "string" },
805
+ port: { type: "string" },
806
+ file: { type: "string" },
807
+ count: { type: "string" },
808
+ seed: { type: "string" },
809
+ force: { type: "boolean" },
810
+ random: { type: "boolean" },
811
+ reset: { type: "boolean" },
812
+ "if-empty": { type: "boolean" },
813
+ "skip-install": { type: "boolean" },
814
+ yes: { type: "boolean" },
815
+ help: { type: "boolean" }
816
+ };
817
+ function port(raw) {
818
+ if (raw === void 0) return void 0;
819
+ if (!/^\d+$/.test(raw)) throw new CliError("--port must be a number");
820
+ return Number(raw);
821
+ }
822
+ async function run(argv, io = consoleIo, cwd = process.cwd()) {
823
+ const { values, positionals } = parseArgs({
824
+ args: [...argv],
825
+ options: OPTIONS,
826
+ allowPositionals: true,
827
+ strict: true
828
+ });
829
+ const [group, command, argument] = positionals;
830
+ if (group === void 0 || values.help === true) {
831
+ io.out(group === "db" ? DB_HELP : HELP);
832
+ return;
833
+ }
834
+ const config = async () => loadConfig(cwd, values.config);
835
+ switch (`${group} ${command ?? ""}`.trim()) {
836
+ case "db push": return push(await config(), io, { force: values.force });
837
+ case "db generate": return generate(await config(), io, { name: values.name });
838
+ case "db migrate": return migrate(await config(), io);
839
+ case "db seed": return seed(await config(), io, {
840
+ file: values.file,
841
+ random: values.random,
842
+ count: values.count === void 0 ? void 0 : Number(values.count),
843
+ seed: values.seed === void 0 ? void 0 : Number(values.seed),
844
+ reset: values.reset,
845
+ ifEmpty: values["if-empty"]
846
+ });
847
+ case "db status": return status(await config(), io);
848
+ case "db studio": return studio(await config(), io, { port: port(values.port) });
849
+ case "db":
850
+ io.out(DB_HELP);
851
+ return;
852
+ case "make collection":
853
+ if (argument === void 0) throw new CliError("Usage: typion make collection <name>");
854
+ return makeCollection(await config(), io, argument, { dir: values.dir });
855
+ case "make plugin":
856
+ if (argument === void 0) throw new CliError("Usage: typion make plugin <name>");
857
+ return makePlugin(await config(), io, argument, { dir: values.dir });
858
+ case "make": throw new CliError("Usage: typion make collection|plugin <name>");
859
+ case "init": return init(cwd, io, {
860
+ driver: values.driver,
861
+ yes: values.yes,
862
+ skipInstall: values["skip-install"]
863
+ });
864
+ default: throw new CliError(`Unknown command '${[group, command].filter(Boolean).join(" ")}'. Try \`typion --help\`.`);
865
+ }
866
+ }
867
+ async function main(argv) {
868
+ try {
869
+ await run(argv);
870
+ return 0;
871
+ } catch (error) {
872
+ if (error instanceof CliError || error instanceof ConfigError) {
873
+ console.error(error.message);
874
+ return 1;
875
+ }
876
+ console.error(error);
877
+ return 1;
878
+ }
879
+ }
880
+ //#endregion
881
+ export { CliError, main, run };
882
+
883
+ //# sourceMappingURL=index.js.map