typion 0.0.0-stage → 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 (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 +15 -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 +827 -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-CpLQy7OI.d.ts +2098 -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-D03-rJ-t.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 +28 -0
  53. package/dist/testing/index.js +92 -0
  54. package/dist/testing/index.js.map +1 -0
  55. package/dist/typion-TJWDkHGh.js +1186 -0
  56. package/dist/typion-TJWDkHGh.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 +112 -0
  62. package/docs/auth.md +140 -0
  63. package/docs/collections.md +168 -0
  64. package/docs/database.md +74 -0
  65. package/docs/features/localization.md +112 -0
  66. package/docs/features/multi-tenant.md +165 -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 +184 -0
  71. package/docs/queries.md +197 -0
  72. package/docs/testing.md +85 -0
  73. package/package.json +125 -3
@@ -0,0 +1,827 @@
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-TJWDkHGh.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
+ function packageManager(root) {
408
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
409
+ if (existsSync(join(root, "yarn.lock"))) return "yarn";
410
+ if (existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))) return "bun";
411
+ if (existsSync(join(root, "package-lock.json"))) return "npm";
412
+ const agent = process.env.npm_config_user_agent ?? "";
413
+ if (agent.startsWith("yarn")) return "yarn";
414
+ if (agent.startsWith("bun")) return "bun";
415
+ if (agent.startsWith("pnpm")) return "pnpm";
416
+ return "npm";
417
+ }
418
+ function installCommand(manager, packages, dev) {
419
+ const flag = dev ? manager === "npm" ? "--save-dev" : "-D" : "";
420
+ return {
421
+ command: manager,
422
+ args: [
423
+ manager === "npm" ? "install" : "add",
424
+ ...flag === "" ? [] : [flag],
425
+ ...packages
426
+ ]
427
+ };
428
+ }
429
+ async function confirm(question, io) {
430
+ if (!process.stdin.isTTY) {
431
+ io.out(`${question} — not a terminal, so nothing was installed. Re-run with --yes.`);
432
+ return false;
433
+ }
434
+ const rl = createInterface({
435
+ input: process.stdin,
436
+ output: process.stdout
437
+ });
438
+ try {
439
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
440
+ return answer === "" || answer === "y" || answer === "yes";
441
+ } finally {
442
+ rl.close();
443
+ }
444
+ }
445
+ function run$1(command, args, cwd, io) {
446
+ io.out(`> ${command} ${args.join(" ")}`);
447
+ const result = spawnSync(command, [...args], {
448
+ cwd,
449
+ stdio: "inherit"
450
+ });
451
+ if (result.error !== void 0 || result.status !== null && result.status !== 0) throw new CliError(`\`${command} ${args.join(" ")}\` failed; install them yourself and re-run.`);
452
+ }
453
+ function readJson(path) {
454
+ try {
455
+ return JSON.parse(readFileSync(path, "utf8"));
456
+ } catch {
457
+ return null;
458
+ }
459
+ }
460
+ function inspect(cwd) {
461
+ const pkg = readJson(join(cwd, "package.json"));
462
+ if (pkg === null) throw new CliError(`No package.json in ${cwd}: run this inside your project.`);
463
+ const deps = {
464
+ ...pkg.dependencies,
465
+ ...pkg.devDependencies
466
+ };
467
+ const src = existsSync(join(cwd, "src")) ? join(cwd, "src") : cwd;
468
+ const appDir = [join(src, "app"), join(cwd, "app")].find((dir) => existsSync(dir)) ?? null;
469
+ return {
470
+ root: cwd,
471
+ src,
472
+ framework: deps.next !== void 0 ? "next" : deps.hono !== void 0 ? "hono" : "none",
473
+ dependencies: new Set(Object.keys(deps)),
474
+ appDir
475
+ };
476
+ }
477
+ function write(path, contents, io) {
478
+ if (existsSync(path)) {
479
+ io.out(`Kept ${relative(process.cwd(), path)} (it exists already).`);
480
+ return false;
481
+ }
482
+ mkdirSync(dirname(path), { recursive: true });
483
+ writeFileSync(path, contents);
484
+ io.out(`Created ${relative(process.cwd(), path)}.`);
485
+ return true;
486
+ }
487
+ const COLLECTION = `import { text } from 'drizzle-orm/pg-core'
488
+ import { collection } from 'typion'
489
+
490
+ export const pages = collection('pages', {
491
+ columns: {
492
+ title: text().notNull(),
493
+ slug: text().notNull().unique(),
494
+ },
495
+ public: true,
496
+ })
497
+ `;
498
+ function configFile(driver) {
499
+ 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'`}
500
+ import { pages } from './typion/collections/pages.ts'
501
+
502
+ // What the application knows about a caller: rules, hooks and field rules read it. Registered below as well, since
503
+ // field rules are typed before this config exists.
504
+ export type AppContext = {
505
+ actorId: string | null
506
+ }
507
+
508
+ // A constant of its own: the collections read it to type their options and their columns, which happens before
509
+ // this config exists. It takes the features (\`localization\`, \`multiTenant\`) and the plugins (\`plugins: […]\`).
510
+ export const setup = defineSetup({})
511
+
512
+ export const config = defineConfig({
513
+ setup,
514
+ collections: [pages],
515
+ ${driver === "pglite" ? ` db: pglite({ dataDir: '.pglite' }),` : ` db: dbAdapter((relations) => drizzle(process.env.DATABASE_URL!, { relations }), {\n url: process.env.DATABASE_URL,\n }),`}
516
+ // Who is calling. Return your own shape; declare the return type, the body may query \`system\`.
517
+ context: async ({ headers }): Promise<AppContext> => {
518
+ void headers
519
+ return { actorId: null }
520
+ },
521
+ })
522
+
523
+ declare module 'typion' {
524
+ interface Register {
525
+ setup: typeof setup
526
+ config: typeof config
527
+ context: AppContext
528
+ }
529
+ }
530
+ `;
531
+ }
532
+ function allowTsImports(root, io) {
533
+ const path = join(root, "tsconfig.json");
534
+ if (!existsSync(path)) return;
535
+ const json = readJson(path);
536
+ const options = json?.compilerOptions ?? {};
537
+ if (options.allowImportingTsExtensions === true || options.rewriteRelativeImportExtensions === true) return;
538
+ const flag = options.noEmit === true ? "allowImportingTsExtensions" : "rewriteRelativeImportExtensions";
539
+ if (json === null) {
540
+ io.out(`Add "${flag}": true to the compilerOptions of tsconfig.json: Typion's imports end in .ts.`);
541
+ return;
542
+ }
543
+ json.compilerOptions = {
544
+ ...options,
545
+ [flag]: true
546
+ };
547
+ writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`);
548
+ io.out(`Set ${flag} in tsconfig.json: Typion's imports end in .ts.`);
549
+ }
550
+ const ROUTE = `import { rest } from '@typion/rest'
551
+ import { rpc } from '@typion/rpc'
552
+ import { routeHandlers } from 'typion/next'
553
+ import { config } from '@/typion.config.ts'
554
+
555
+ // What the app serves over HTTP: a resource per collection, and the endpoint the browser client posts to. Leave
556
+ // out what you do not use.
557
+ export const { GET, POST, PATCH, PUT, DELETE, OPTIONS } = await routeHandlers(config, {
558
+ transports: [rest(), rpc()],
559
+ })
560
+ `;
561
+ async function init(cwd, io, options) {
562
+ const project = inspect(cwd);
563
+ const driver = options.driver === "postgres" ? "postgres" : "pglite";
564
+ if (write(join(project.src, "typion.config.ts"), configFile(driver), io)) write(join(project.src, "typion", "collections", "pages.ts"), COLLECTION, io);
565
+ allowTsImports(project.root, io);
566
+ if (project.framework === "next" && project.appDir !== null) write(join(project.appDir, "api", "[...path]", "route.ts"), ROUTE, io);
567
+ writeAgentNotes(project.root, io);
568
+ await installDependencies(project, driver, io, options);
569
+ io.out("");
570
+ io.out("Next:");
571
+ io.out(" 1. turn on what you need: `softDelete` or `versions` on a collection, `localization`, `multiTenant` or `plugins` in `defineSetup`");
572
+ 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`");
573
+ if (project.framework === "next") io.out(" 3. read from a server component with `const app = await getApp(config)` (typion/next)");
574
+ if (project.framework === "hono") {
575
+ io.out(" 3. mount the transports in your Hono app: `.all('/api/*', typionRoutes(config, { transports: [rest(), rpc()] }))` (typion/hono)");
576
+ io.out(" 4. bind the caller in your own routes with `typionMiddleware(config)`: `c.var.typion`");
577
+ }
578
+ }
579
+ async function installDependencies(project, driver, io, options) {
580
+ const deps = [
581
+ "typion",
582
+ "drizzle-orm",
583
+ driver === "pglite" ? "@electric-sql/pglite" : "pg",
584
+ ...project.framework === "none" ? [] : ["@typion/rest", "@typion/rpc"]
585
+ ];
586
+ const dev = ["drizzle-kit"];
587
+ const spec = (name) => name.startsWith("drizzle-") ? `${name}@${DRIZZLE}` : name;
588
+ const missing = deps.filter((name) => !project.dependencies.has(name)).map(spec);
589
+ const missingDev = dev.filter((name) => !project.dependencies.has(name)).map(spec);
590
+ if (missing.length === 0 && missingDev.length === 0) return;
591
+ const manager = packageManager(project.root);
592
+ const lines = [...missing.length === 0 ? [] : [installCommand(manager, missing, false)], ...missingDev.length === 0 ? [] : [installCommand(manager, missingDev, true)]];
593
+ io.out("");
594
+ io.out("Missing packages:");
595
+ for (const line of lines) io.out(` ${line.command} ${line.args.join(" ")}`);
596
+ if (options.skipInstall === true) return;
597
+ if (!(options.yes === true || await confirm(`Install them with ${manager}?`, io))) return;
598
+ for (const line of lines) run$1(line.command, line.args, project.root, io);
599
+ }
600
+ //#endregion
601
+ //#region src/cli/make.ts
602
+ function pascal(name) {
603
+ return name.replace(/(^|[-_ ])(\w)/g, (_, __, char) => char.toUpperCase());
604
+ }
605
+ function camel(name) {
606
+ const upper = pascal(name);
607
+ return upper.charAt(0).toLowerCase() + upper.slice(1);
608
+ }
609
+ function collectionsDir(loaded, given) {
610
+ if (given !== void 0) return isAbsolute(given) ? given : resolve(process.cwd(), given);
611
+ const candidates = [join(loaded.dir, "typion", "collections"), join(loaded.dir, "collections")];
612
+ return candidates.find((dir) => existsSync(dir)) ?? candidates[0];
613
+ }
614
+ function template(name, tenant) {
615
+ return `import { text } from 'drizzle-orm/pg-core'
616
+ import { collection } from 'typion'
617
+
618
+ export const ${camel(name)} = collection('${camel(name)}', {
619
+ columns: {
620
+ // Plain Drizzle columns; the features and plugins add their own options next to \`columns\`.
621
+ title: text().notNull(),
622
+ },${tenant ? "\n tenant: true," : ""}
623
+ }).define({
624
+ // Deny by default: an operation without a rule is refused. Give each one the rule it needs, for example
625
+ // \`read: ({ ctx }) => ctx.actorId !== null\`.
626
+ access: {},
627
+ })
628
+ `;
629
+ }
630
+ function importPath(from, to) {
631
+ const path = relative(from, to).replaceAll("\\", "/");
632
+ return path.startsWith(".") ? path : `./${path}`;
633
+ }
634
+ function register(loaded, name, file, io) {
635
+ const source = readFileSync(loaded.path, "utf8");
636
+ const specifier = importPath(loaded.dir, file).replace(/\.ts$/, ".ts");
637
+ const line = `import { ${camel(name)} } from '${specifier}'`;
638
+ const list = /collections:\s*\[([^\]]*)\]/.exec(source);
639
+ if (source.includes(line) || list === null) {
640
+ io.out("Add it to the config:");
641
+ io.out(` ${line}`);
642
+ io.out(` collections: [ …, ${camel(name)} ]`);
643
+ return;
644
+ }
645
+ const entries = list[1].trim();
646
+ const updated = source.replace(/(^import .*\n)(?![\s\S]*^import )/m, `$1${line}\n`).replace(/collections:\s*\[([^\]]*)\]/, `collections: [${entries === "" ? "" : `${entries.replace(/,\s*$/, "")}, `}${camel(name)}]`);
647
+ writeFileSync(loaded.path, updated);
648
+ io.out(`Registered it in ${relative(process.cwd(), loaded.path)}.`);
649
+ }
650
+ function pluginTemplate(name) {
651
+ const id = camel(name);
652
+ const Kinds = `${pascal(name)}Kinds`;
653
+ const Options = `${pascal(name)}Options`;
654
+ return `import { definePlugin } from 'typion'
655
+ import type { CollectionKinds } from 'typion'
656
+
657
+ // What this plugin adds to every collection. A collection that does not set the option gets nothing.
658
+ export type ${Options} = { ${id}?: boolean }
659
+
660
+ // How the option turns into columns and into engine options, read from \`this['options']\`.
661
+ interface ${Kinds} extends CollectionKinds {
662
+ readonly columns: this['options'] extends { ${id}: true } ? {} : {}
663
+ readonly entity: this['options'] extends { ${id}: true } ? {} : {}
664
+ }
665
+
666
+ export function ${id}() {
667
+ return definePlugin({
668
+ id: '${id}',
669
+ collection: {
670
+ $options: {} as ${Options},
671
+ $kinds: {} as ${Kinds},
672
+ // Option keys this plugin reads, so they never reach the engine as entity options.
673
+ consumes: ['${id}'],
674
+ columns: (options) => (options.${id} === true ? {} : {}),
675
+ entity: (options) => (options.${id} === true ? {} : {}),
676
+ },
677
+ // Tables, relations, entities, hooks, api, routes, errors and context go here too; see @typion/audit.
678
+ })
679
+ }
680
+ `;
681
+ }
682
+ function makePlugin(loaded, io, name, options) {
683
+ if (!/^[a-z][a-z0-9_-]*$/i.test(name)) throw new CliError(`'${name}' is not a plugin name (letters, digits, dash, underscore)`);
684
+ const dir = options.dir === void 0 ? join(loaded.dir, "typion", "plugins") : isAbsolute(options.dir) ? options.dir : resolve(process.cwd(), options.dir);
685
+ const file = join(dir, `${camel(name)}.ts`);
686
+ if (existsSync(file)) throw new CliError(`${relative(process.cwd(), file)} already exists`);
687
+ mkdirSync(dir, { recursive: true });
688
+ writeFileSync(file, pluginTemplate(name));
689
+ io.out(`Created ${relative(process.cwd(), file)}.`);
690
+ io.out("Install it in the config:");
691
+ io.out(` import { ${camel(name)} } from './${relative(loaded.dir, file).replaceAll("\\", "/")}'`);
692
+ io.out(` export const setup = defineSetup({ plugins: [ …, ${camel(name)}() ] })`);
693
+ }
694
+ function makeCollection(loaded, io, name, options) {
695
+ if (!/^[a-z][a-z0-9_-]*$/i.test(name)) throw new CliError(`'${name}' is not a collection name (letters, digits, dash, underscore)`);
696
+ const dir = collectionsDir(loaded, options.dir);
697
+ const file = join(dir, `${camel(name)}.ts`);
698
+ if (existsSync(file)) throw new CliError(`${relative(process.cwd(), file)} already exists`);
699
+ mkdirSync(dirname(file), { recursive: true });
700
+ const tenant = loaded.config.definition.plugins.some((plugin) => plugin.id === "multiTenant");
701
+ writeFileSync(file, template(name, tenant));
702
+ io.out(`Created ${relative(process.cwd(), file)}.`);
703
+ register(loaded, name, file, io);
704
+ io.out("Then `typion db push` (or `typion db generate` + `typion db migrate`).");
705
+ }
706
+ //#endregion
707
+ //#region src/cli/index.ts
708
+ const HELP = `typion — the CLI of a Typion project
709
+
710
+ Usage
711
+ typion <group> <command> [options]
712
+
713
+ Database (everything that touches the schema, through drizzle-kit)
714
+ typion db push [--force] make the database match the config (development)
715
+ typion db generate [--name x] write the SQL of what changed, with its snapshot
716
+ typion db migrate apply the migrations the database has not applied
717
+ typion db seed [--file path] run the project's seed through the engine
718
+ [--random] ask drizzle-seed for rows instead (--count, --seed)
719
+ [--reset] empty every table of the config first
720
+ [--if-empty] do nothing when a collection already holds rows
721
+ typion db status what is applied, what is pending
722
+ typion db studio [--port n] open Drizzle Studio on this config
723
+
724
+ Project
725
+ typion make collection <name> [--dir path] a new collection, registered in the config
726
+ typion make plugin <name> [--dir path] a plugin skeleton for this project
727
+ typion init [--driver pglite|postgres] [--yes] [--skip-install]
728
+ set Typion up in a project that exists, with the
729
+ notes its agents read (run it again after upgrading)
730
+
731
+ Options
732
+ --config <path> the config to read; by default the nearest typion.config.ts
733
+ --help this text (also: typion db --help)
734
+ `;
735
+ const DB_HELP = `typion db — the database of a Typion config
736
+
737
+ push [--force] make the database match the config (development)
738
+ generate [--name x] write the SQL of what changed, with its snapshot
739
+ migrate apply the migrations the database has not applied
740
+ seed [--file|--random] fill the database: the project's seed, or drizzle-seed rows
741
+ status what is applied, what is pending
742
+ studio [--port n] open Drizzle Studio on this config
743
+ `;
744
+ const OPTIONS = {
745
+ config: { type: "string" },
746
+ name: { type: "string" },
747
+ dir: { type: "string" },
748
+ driver: { type: "string" },
749
+ port: { type: "string" },
750
+ file: { type: "string" },
751
+ count: { type: "string" },
752
+ seed: { type: "string" },
753
+ force: { type: "boolean" },
754
+ random: { type: "boolean" },
755
+ reset: { type: "boolean" },
756
+ "if-empty": { type: "boolean" },
757
+ "skip-install": { type: "boolean" },
758
+ yes: { type: "boolean" },
759
+ help: { type: "boolean" }
760
+ };
761
+ function port(raw) {
762
+ if (raw === void 0) return void 0;
763
+ if (!/^\d+$/.test(raw)) throw new CliError("--port must be a number");
764
+ return Number(raw);
765
+ }
766
+ async function run(argv, io = consoleIo, cwd = process.cwd()) {
767
+ const { values, positionals } = parseArgs({
768
+ args: [...argv],
769
+ options: OPTIONS,
770
+ allowPositionals: true,
771
+ strict: true
772
+ });
773
+ const [group, command, argument] = positionals;
774
+ if (group === void 0 || values.help === true) {
775
+ io.out(group === "db" ? DB_HELP : HELP);
776
+ return;
777
+ }
778
+ const config = async () => loadConfig(cwd, values.config);
779
+ switch (`${group} ${command ?? ""}`.trim()) {
780
+ case "db push": return push(await config(), io, { force: values.force });
781
+ case "db generate": return generate(await config(), io, { name: values.name });
782
+ case "db migrate": return migrate(await config(), io);
783
+ case "db seed": return seed(await config(), io, {
784
+ file: values.file,
785
+ random: values.random,
786
+ count: values.count === void 0 ? void 0 : Number(values.count),
787
+ seed: values.seed === void 0 ? void 0 : Number(values.seed),
788
+ reset: values.reset,
789
+ ifEmpty: values["if-empty"]
790
+ });
791
+ case "db status": return status(await config(), io);
792
+ case "db studio": return studio(await config(), io, { port: port(values.port) });
793
+ case "db":
794
+ io.out(DB_HELP);
795
+ return;
796
+ case "make collection":
797
+ if (argument === void 0) throw new CliError("Usage: typion make collection <name>");
798
+ return makeCollection(await config(), io, argument, { dir: values.dir });
799
+ case "make plugin":
800
+ if (argument === void 0) throw new CliError("Usage: typion make plugin <name>");
801
+ return makePlugin(await config(), io, argument, { dir: values.dir });
802
+ case "make": throw new CliError("Usage: typion make collection|plugin <name>");
803
+ case "init": return init(cwd, io, {
804
+ driver: values.driver,
805
+ yes: values.yes,
806
+ skipInstall: values["skip-install"]
807
+ });
808
+ default: throw new CliError(`Unknown command '${[group, command].filter(Boolean).join(" ")}'. Try \`typion --help\`.`);
809
+ }
810
+ }
811
+ async function main(argv) {
812
+ try {
813
+ await run(argv);
814
+ return 0;
815
+ } catch (error) {
816
+ if (error instanceof CliError || error instanceof ConfigError) {
817
+ console.error(error.message);
818
+ return 1;
819
+ }
820
+ console.error(error);
821
+ return 1;
822
+ }
823
+ }
824
+ //#endregion
825
+ export { CliError, main, run };
826
+
827
+ //# sourceMappingURL=index.js.map