@strav/cli 0.4.31 → 1.0.0-alpha.5

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 (63) hide show
  1. package/package.json +17 -41
  2. package/src/binder.ts +88 -0
  3. package/src/command.ts +297 -0
  4. package/src/config_list.ts +42 -0
  5. package/src/config_show.ts +50 -0
  6. package/src/console_provider.ts +46 -0
  7. package/src/exit_codes.ts +26 -0
  8. package/src/index.ts +60 -2
  9. package/src/key_generate.ts +66 -0
  10. package/src/make/index.ts +17 -0
  11. package/src/make/make_command_file.ts +27 -0
  12. package/src/make/make_controller.ts +24 -0
  13. package/src/make/make_factory.ts +25 -0
  14. package/src/make/make_job.ts +25 -0
  15. package/src/make/make_mail.ts +27 -0
  16. package/src/make/make_middleware.ts +23 -0
  17. package/src/make/make_migration.ts +48 -0
  18. package/src/make/make_model.ts +91 -0
  19. package/src/make/make_notification.ts +23 -0
  20. package/src/make/make_policy.ts +24 -0
  21. package/src/make/make_provider.ts +29 -0
  22. package/src/make/make_repository.ts +30 -0
  23. package/src/make/make_request.ts +24 -0
  24. package/src/make/make_seeder.ts +23 -0
  25. package/src/make/make_test.ts +22 -0
  26. package/src/make_command.ts +69 -0
  27. package/src/run_cli.ts +121 -0
  28. package/src/scaffold_console_provider.ts +45 -0
  29. package/src/signature.ts +171 -0
  30. package/src/subset_boot.ts +51 -0
  31. package/src/util_console_provider.ts +18 -0
  32. package/src/cli/bootstrap.ts +0 -82
  33. package/src/cli/command_loader.ts +0 -180
  34. package/src/cli/index.ts +0 -3
  35. package/src/cli/strav.ts +0 -13
  36. package/src/commands/db_seed.ts +0 -77
  37. package/src/commands/db_setup_roles.ts +0 -101
  38. package/src/commands/generate_api.ts +0 -93
  39. package/src/commands/generate_key.ts +0 -47
  40. package/src/commands/generate_models.ts +0 -49
  41. package/src/commands/generate_seeder.ts +0 -68
  42. package/src/commands/migration_compare.ts +0 -167
  43. package/src/commands/migration_fresh.ts +0 -148
  44. package/src/commands/migration_generate.ts +0 -84
  45. package/src/commands/migration_rollback.ts +0 -54
  46. package/src/commands/migration_run.ts +0 -45
  47. package/src/commands/package_install.ts +0 -161
  48. package/src/commands/queue_flush.ts +0 -35
  49. package/src/commands/queue_retry.ts +0 -34
  50. package/src/commands/queue_work.ts +0 -101
  51. package/src/commands/scheduler_work.ts +0 -46
  52. package/src/commands/tenant_create.ts +0 -35
  53. package/src/commands/tenant_delete.ts +0 -64
  54. package/src/commands/tenant_list.ts +0 -39
  55. package/src/config/loader.ts +0 -50
  56. package/src/generators/api_generator.ts +0 -1035
  57. package/src/generators/config.ts +0 -113
  58. package/src/generators/doc_generator.ts +0 -996
  59. package/src/generators/index.ts +0 -11
  60. package/src/generators/model_generator.ts +0 -596
  61. package/src/generators/route_generator.ts +0 -187
  62. package/src/generators/test_generator.ts +0 -1667
  63. package/tsconfig.json +0 -5
@@ -1,996 +0,0 @@
1
- import { join } from 'node:path'
2
- import { Archetype } from '@strav/database/schema/types'
3
- import type { SchemaDefinition } from '@strav/database/schema/types'
4
- import type {
5
- DatabaseRepresentation,
6
- TableDefinition,
7
- ColumnDefinition,
8
- } from '@strav/database/schema/database_representation'
9
- import type { FieldDefinition, FieldValidator } from '@strav/database/schema/field_definition'
10
- import type { PostgreSQLCustomType } from '@strav/database/schema/postgres'
11
- import {
12
- toSnakeCase,
13
- toCamelCase,
14
- toPascalCase,
15
- pluralize,
16
- } from '@strav/kernel/helpers/strings'
17
- import { existsSync } from 'node:fs'
18
- import type { GeneratedFile } from './model_generator.ts'
19
- import type { GeneratorConfig, GeneratorPaths, WriteResult } from './config.ts'
20
- import { resolvePaths } from './config.ts'
21
- import { ApiRouting, toRouteSegment, toChildSegment } from './route_generator.ts'
22
- import type { ApiRoutingConfig } from './route_generator.ts'
23
-
24
- // ---------------------------------------------------------------------------
25
- // Archetype behaviour (mirrored from api_generator.ts)
26
- // ---------------------------------------------------------------------------
27
-
28
- const ARCHETYPE_CONTROLLER: Record<Archetype, string[]> = {
29
- [Archetype.Entity]: ['index', 'show', 'store', 'update', 'destroy'],
30
- [Archetype.Attribute]: ['index', 'show', 'store', 'update', 'destroy'],
31
- [Archetype.Contribution]: ['index', 'show', 'store', 'update', 'destroy'],
32
- [Archetype.Reference]: ['index', 'show', 'store', 'update', 'destroy'],
33
- [Archetype.Component]: ['index', 'show', 'update'],
34
- [Archetype.Event]: ['index', 'show', 'store'],
35
- [Archetype.Configuration]: ['show', 'update', 'destroy'],
36
- [Archetype.Association]: [],
37
- }
38
-
39
- const DEPENDENT_ARCHETYPES: Set<Archetype> = new Set([
40
- Archetype.Component,
41
- Archetype.Attribute,
42
- Archetype.Event,
43
- Archetype.Configuration,
44
- Archetype.Contribution,
45
- ])
46
-
47
- const SYSTEM_COLUMNS = new Set(['id', 'created_at', 'updated_at', 'deleted_at'])
48
-
49
- const ARCHETYPE_DESCRIPTIONS: Record<Archetype, string> = {
50
- [Archetype.Entity]: 'A standalone entity with full CRUD operations and soft delete support.',
51
- [Archetype.Contribution]:
52
- 'A user-contributed resource under a parent entity. Full CRUD with soft delete.',
53
- [Archetype.Reference]:
54
- 'A lookup/reference table with full CRUD. No soft delete &mdash; records are permanently removed.',
55
- [Archetype.Attribute]: 'A dependent attribute of a parent entity. Full CRUD with soft delete.',
56
- [Archetype.Component]:
57
- 'A tightly-coupled component of a parent entity. Can be listed, viewed, and updated &mdash; but not independently created or deleted.',
58
- [Archetype.Event]:
59
- 'An append-only event log under a parent entity. Events can be listed, viewed, and appended &mdash; but never updated or deleted.',
60
- [Archetype.Configuration]:
61
- 'A singleton configuration record under a parent entity. One record per parent. Supports show, upsert, and reset.',
62
- [Archetype.Association]: 'A join table linking two entities.',
63
- }
64
-
65
- const API_DEFAULTS: ApiRoutingConfig = {
66
- routing: ApiRouting.Prefix,
67
- prefix: '/api',
68
- subdomain: 'api',
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // DocGenerator
73
- // ---------------------------------------------------------------------------
74
-
75
- export default class DocGenerator {
76
- private apiConfig: ApiRoutingConfig
77
- private paths: GeneratorPaths
78
- private schemaMap: Map<string, SchemaDefinition>
79
-
80
- constructor(
81
- private schemas: SchemaDefinition[],
82
- private representation: DatabaseRepresentation,
83
- config?: GeneratorConfig,
84
- apiConfig?: Partial<ApiRoutingConfig>
85
- ) {
86
- this.apiConfig = { ...API_DEFAULTS, ...apiConfig }
87
- this.paths = resolvePaths(config)
88
- this.schemaMap = new Map(schemas.map(s => [s.name, s]))
89
- }
90
-
91
- generate(): GeneratedFile[] {
92
- return [this.generateIndexPage()]
93
- }
94
-
95
- async writeAll(force?: boolean): Promise<WriteResult> {
96
- const files = this.generate()
97
- const written: GeneratedFile[] = []
98
- const skipped: GeneratedFile[] = []
99
-
100
- for (const file of files) {
101
- if (existsSync(file.path) && !force) {
102
- skipped.push(file)
103
- continue
104
- }
105
- await Bun.write(file.path, file.content)
106
- written.push(file)
107
- }
108
-
109
- return { written, skipped }
110
- }
111
-
112
- // ---------------------------------------------------------------------------
113
- // Main page
114
- // ---------------------------------------------------------------------------
115
-
116
- private generateIndexPage(): GeneratedFile {
117
- const routable = this.schemas.filter(s => s.archetype !== Archetype.Association)
118
-
119
- // Group by parent for sidebar
120
- const rootSchemas: SchemaDefinition[] = []
121
- const childrenOf = new Map<string, SchemaDefinition[]>()
122
-
123
- for (const s of routable) {
124
- const routeParent = s.parents?.[0]
125
- if (DEPENDENT_ARCHETYPES.has(s.archetype) && routeParent) {
126
- if (!childrenOf.has(routeParent)) childrenOf.set(routeParent, [])
127
- childrenOf.get(routeParent)!.push(s)
128
- } else {
129
- rootSchemas.push(s)
130
- }
131
- }
132
-
133
- const sidebar = this.buildSidebar(rootSchemas, childrenOf)
134
- const content = this.buildContent(routable)
135
-
136
- const html = `<!DOCTYPE html>
137
- <html lang="en" class="scroll-smooth">
138
- <head>
139
- <meta charset="UTF-8">
140
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
141
- <title>API Reference</title>
142
- <script src="https://cdn.tailwindcss.com"></script>
143
- <link rel="preconnect" href="https://fonts.googleapis.com">
144
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
145
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
146
- <script>
147
- tailwind.config = {
148
- theme: {
149
- extend: {
150
- fontFamily: {
151
- sans: ['Inter', 'system-ui', 'sans-serif'],
152
- mono: ['JetBrains Mono', 'monospace'],
153
- },
154
- },
155
- },
156
- }
157
- </script>
158
- <style>
159
- html { scroll-padding-top: 6rem; }
160
- .sidebar-link.active { color: #fff; background: rgba(255,255,255,0.06); }
161
- .sidebar-link:hover { color: #e4e4e7; background: rgba(255,255,255,0.04); }
162
- .method-badge { font-size: 0.65rem; font-weight: 600; letter-spacing: 0.05em; padding: 0.15rem 0.5rem; border-radius: 9999px; text-transform: uppercase; }
163
- pre code { font-size: 0.8125rem; line-height: 1.625; }
164
- @media (max-width: 1023px) {
165
- .sidebar { transform: translateX(-100%); position: fixed; z-index: 50; }
166
- .sidebar.open { transform: translateX(0); }
167
- }
168
- </style>
169
- </head>
170
- <body class="bg-white font-sans text-zinc-900 antialiased">
171
-
172
- <!-- Mobile menu button -->
173
- <button onclick="document.getElementById('sidebar').classList.toggle('open')"
174
- class="lg:hidden fixed top-4 left-4 z-50 p-2 rounded-lg bg-zinc-900 text-white shadow-lg">
175
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
176
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
177
- </svg>
178
- </button>
179
-
180
- <!-- Sidebar -->
181
- <aside id="sidebar" class="sidebar fixed inset-y-0 left-0 w-72 bg-zinc-900 overflow-y-auto transition-transform duration-200 lg:translate-x-0">
182
- <div class="px-6 py-8">
183
- <h1 class="text-lg font-semibold text-white tracking-tight">API Reference</h1>
184
- <p class="mt-1 text-xs text-zinc-500">Generated by Strav</p>
185
- </div>
186
- <nav class="px-3 pb-8">
187
- ${sidebar}
188
- </nav>
189
- </aside>
190
-
191
- <!-- Main content -->
192
- <main class="lg:pl-72">
193
- <div class="max-w-4xl mx-auto px-6 sm:px-8 py-16">
194
- ${content}
195
- </div>
196
- </main>
197
-
198
- <script>
199
- // Highlight active sidebar link on scroll
200
- const observer = new IntersectionObserver(entries => {
201
- for (const entry of entries) {
202
- if (entry.isIntersecting) {
203
- document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
204
- const link = document.querySelector('.sidebar-link[href="#' + entry.target.id + '"]');
205
- if (link) link.classList.add('active');
206
- }
207
- }
208
- }, { rootMargin: '-20% 0px -70% 0px' });
209
- document.querySelectorAll('section[id]').forEach(s => observer.observe(s));
210
-
211
- // Close mobile sidebar on link click
212
- document.querySelectorAll('.sidebar-link').forEach(link => {
213
- link.addEventListener('click', () => {
214
- document.getElementById('sidebar').classList.remove('open');
215
- });
216
- });
217
- </script>
218
- </body>
219
- </html>`
220
-
221
- return {
222
- path: join(this.paths.docs, 'index.html'),
223
- content: html,
224
- }
225
- }
226
-
227
- // ---------------------------------------------------------------------------
228
- // Sidebar
229
- // ---------------------------------------------------------------------------
230
-
231
- private buildSidebar(
232
- rootSchemas: SchemaDefinition[],
233
- childrenOf: Map<string, SchemaDefinition[]>
234
- ): string {
235
- const lines: string[] = []
236
-
237
- // Introduction & Auth
238
- lines.push(' <div class="mb-6">')
239
- lines.push(
240
- ' <p class="px-3 text-[0.6875rem] font-semibold uppercase tracking-wider text-zinc-500 mb-2">Getting Started</p>'
241
- )
242
- lines.push(
243
- ' <a href="#introduction" class="sidebar-link block px-3 py-1.5 text-sm text-zinc-400 rounded-md transition-colors">Introduction</a>'
244
- )
245
- lines.push(
246
- ' <a href="#authentication" class="sidebar-link block px-3 py-1.5 text-sm text-zinc-400 rounded-md transition-colors">Authentication</a>'
247
- )
248
- lines.push(' </div>')
249
-
250
- // Resources
251
- lines.push(' <div>')
252
- lines.push(
253
- ' <p class="px-3 text-[0.6875rem] font-semibold uppercase tracking-wider text-zinc-500 mb-2">Resources</p>'
254
- )
255
-
256
- const emitSchema = (schema: SchemaDefinition): void => {
257
- const anchor = toSnakeCase(schema.name)
258
- const label = this.displayName(schema.name)
259
- lines.push(
260
- ` <a href="#${anchor}" class="sidebar-link block px-3 py-1.5 text-sm text-zinc-400 rounded-md transition-colors">${label}</a>`
261
- )
262
-
263
- const children = childrenOf.get(schema.name)
264
- if (children?.length) {
265
- for (const child of children) emitSchema(child)
266
- }
267
- }
268
-
269
- for (const schema of rootSchemas) emitSchema(schema)
270
-
271
- lines.push(' </div>')
272
- return lines.join('\n')
273
- }
274
-
275
- // ---------------------------------------------------------------------------
276
- // Main content
277
- // ---------------------------------------------------------------------------
278
-
279
- private buildContent(routable: SchemaDefinition[]): string {
280
- const sections: string[] = []
281
-
282
- sections.push(this.buildIntroduction())
283
- sections.push(this.buildAuthentication())
284
-
285
- for (const schema of routable) {
286
- const table = this.representation.tables.find(t => t.name === toSnakeCase(schema.name))
287
- if (!table) continue
288
- sections.push(this.buildResourceSection(schema, table))
289
- }
290
-
291
- return sections.join('\n\n')
292
- }
293
-
294
- // ---------------------------------------------------------------------------
295
- // Introduction section
296
- // ---------------------------------------------------------------------------
297
-
298
- private buildIntroduction(): string {
299
- const baseUrl =
300
- this.apiConfig.routing === ApiRouting.Subdomain
301
- ? `https://${this.apiConfig.subdomain}.&lt;domain&gt;`
302
- : `https://&lt;domain&gt;${this.apiConfig.prefix}`
303
-
304
- return ` <section id="introduction" class="mb-20">
305
- <h2 class="text-2xl font-semibold tracking-tight text-zinc-900">Introduction</h2>
306
- <div class="mt-4 text-sm leading-relaxed text-zinc-600 space-y-3">
307
- <p>Welcome to the API reference. This documentation is auto-generated from the application schema definitions.</p>
308
- <div class="rounded-lg border border-zinc-200 overflow-hidden">
309
- <table class="w-full text-sm">
310
- <tbody>
311
- <tr class="border-b border-zinc-100">
312
- <td class="px-4 py-2.5 font-medium text-zinc-700 bg-zinc-50 w-40">Base URL</td>
313
- <td class="px-4 py-2.5 font-mono text-xs text-zinc-600">${baseUrl}</td>
314
- </tr>
315
- <tr class="border-b border-zinc-100">
316
- <td class="px-4 py-2.5 font-medium text-zinc-700 bg-zinc-50">Content-Type</td>
317
- <td class="px-4 py-2.5 font-mono text-xs text-zinc-600">application/json</td>
318
- </tr>
319
- <tr>
320
- <td class="px-4 py-2.5 font-medium text-zinc-700 bg-zinc-50">Authentication</td>
321
- <td class="px-4 py-2.5 font-mono text-xs text-zinc-600">Bearer token</td>
322
- </tr>
323
- </tbody>
324
- </table>
325
- </div>
326
- </div>
327
- </section>`
328
- }
329
-
330
- // ---------------------------------------------------------------------------
331
- // Authentication section
332
- // ---------------------------------------------------------------------------
333
-
334
- private buildAuthentication(): string {
335
- return ` <section id="authentication" class="mb-20">
336
- <h2 class="text-2xl font-semibold tracking-tight text-zinc-900">Authentication</h2>
337
- <div class="mt-4 text-sm leading-relaxed text-zinc-600 space-y-4">
338
- <p>All API endpoints require authentication via a bearer token. Include the token in the <code class="text-xs font-mono bg-zinc-100 px-1.5 py-0.5 rounded">Authorization</code> header of every request.</p>
339
- <div class="rounded-lg bg-zinc-900 p-4 overflow-x-auto">
340
- <pre><code class="text-zinc-100 font-mono">GET ${this.apiConfig.routing === ApiRouting.Prefix ? this.apiConfig.prefix : ''}/resources HTTP/1.1
341
- Host: &lt;domain&gt;
342
- Authorization: Bearer &lt;your-token&gt;
343
- Content-Type: application/json</code></pre>
344
- </div>
345
- <div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
346
- <p class="text-amber-800 text-xs font-medium">Unauthenticated requests</p>
347
- <p class="text-amber-700 text-xs mt-1">Requests without a valid bearer token will receive a <code class="font-mono bg-amber-100 px-1 py-0.5 rounded">401 Unauthenticated</code> response.</p>
348
- </div>
349
- <h3 class="text-base font-semibold text-zinc-900 pt-2">Error responses</h3>
350
- <div class="rounded-lg bg-zinc-900 p-4 overflow-x-auto">
351
- <pre><code class="text-zinc-100 font-mono">// 401 Unauthenticated
352
- { "error": "Unauthenticated" }
353
-
354
- // 422 Validation Error
355
- { "errors": { "fieldName": ["Validation message"] } }
356
-
357
- // 404 Not Found
358
- { "error": "Not Found" }</code></pre>
359
- </div>
360
- </div>
361
- </section>`
362
- }
363
-
364
- // ---------------------------------------------------------------------------
365
- // Per-resource section
366
- // ---------------------------------------------------------------------------
367
-
368
- private buildResourceSection(schema: SchemaDefinition, table: TableDefinition): string {
369
- const anchor = toSnakeCase(schema.name)
370
- const label = this.displayName(schema.name)
371
- const actions = ARCHETYPE_CONTROLLER[schema.archetype] ?? []
372
- const archetypeColor = this.archetypeBadgeColor(schema.archetype)
373
- const description = ARCHETYPE_DESCRIPTIONS[schema.archetype]
374
-
375
- const fieldsTable = this.buildFieldsTable(schema, table)
376
- const endpoints = actions.map(action => this.buildEndpoint(action, schema, table)).join('\n')
377
-
378
- return ` <section id="${anchor}" class="mb-20">
379
- <div class="flex items-center gap-3">
380
- <h2 class="text-2xl font-semibold tracking-tight text-zinc-900">${label}</h2>
381
- <span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-[0.625rem] font-semibold ${archetypeColor}">${schema.archetype}</span>
382
- </div>
383
- <p class="mt-2 text-sm text-zinc-500">${description}</p>
384
- ${schema.parents?.length ? ` <p class="mt-4 text-sm text-zinc-500">Parents: <span class="font-semibold text-zinc-700">${schema.parents.map(p => this.displayName(p)).join(', ')}</span></p>` : ''}
385
-
386
- <div class="mt-6">
387
- <h3 class="text-sm font-semibold text-zinc-900 mb-3">Fields</h3>
388
- ${fieldsTable}
389
- </div>
390
-
391
- <div class="mt-8 space-y-8">
392
- <h3 class="text-sm font-semibold text-zinc-900">Endpoints</h3>
393
- ${endpoints}
394
- </div>
395
- </section>`
396
- }
397
-
398
- // ---------------------------------------------------------------------------
399
- // Fields table
400
- // ---------------------------------------------------------------------------
401
-
402
- private buildFieldsTable(schema: SchemaDefinition, table: TableDefinition): string {
403
- const rows: string[] = []
404
-
405
- for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
406
- if (fieldDef.primaryKey) continue
407
- const colName = toSnakeCase(fieldName)
408
- if (SYSTEM_COLUMNS.has(colName)) continue
409
-
410
- const camelName = toCamelCase(fieldName)
411
- const typeLabel = this.fieldTypeLabel(fieldDef)
412
- const required = fieldDef.required
413
- ? '<span class="text-rose-500 text-[0.625rem] font-semibold">required</span>'
414
- : '<span class="text-zinc-400 text-[0.625rem]">optional</span>'
415
- const sensitive = fieldDef.sensitive
416
- ? ' <span class="text-amber-500 text-[0.625rem] font-semibold">sensitive</span>'
417
- : ''
418
- const validators = this.validatorSummary(fieldDef)
419
- const validatorHtml = validators
420
- ? `<span class="text-zinc-400 text-[0.625rem]">${validators}</span>`
421
- : ''
422
-
423
- rows.push(` <tr class="border-b border-zinc-100 last:border-0">
424
- <td class="py-2.5 pl-4 pr-4 font-mono text-xs text-emerald-600 whitespace-nowrap">${camelName}</td>
425
- <td class="py-2.5 pr-4 text-xs text-zinc-500 whitespace-nowrap">${typeLabel}</td>
426
- <td class="py-2.5 pr-4">${required}${sensitive}</td>
427
- <td class="py-2.5 text-xs text-zinc-400">${validatorHtml}</td>
428
- </tr>`)
429
- }
430
-
431
- if (rows.length === 0) {
432
- return ' <p class="text-xs text-zinc-400 italic">No user-editable fields.</p>'
433
- }
434
-
435
- return ` <div class="rounded-lg border border-zinc-200 overflow-hidden">
436
- <table class="w-full text-sm">
437
- <thead>
438
- <tr class="bg-zinc-50 border-b border-zinc-200">
439
- <th class="text-left px-4 py-2 text-[0.6875rem] font-semibold text-zinc-500 uppercase tracking-wider">Name</th>
440
- <th class="text-left px-4 py-2 text-[0.6875rem] font-semibold text-zinc-500 uppercase tracking-wider">Type</th>
441
- <th class="text-left px-4 py-2 text-[0.6875rem] font-semibold text-zinc-500 uppercase tracking-wider">Status</th>
442
- <th class="text-left px-4 py-2 text-[0.6875rem] font-semibold text-zinc-500 uppercase tracking-wider">Constraints</th>
443
- </tr>
444
- </thead>
445
- <tbody class="px-4">
446
- ${rows.join('\n')}
447
- </tbody>
448
- </table>
449
- </div>`
450
- }
451
-
452
- // ---------------------------------------------------------------------------
453
- // Per-endpoint block
454
- // ---------------------------------------------------------------------------
455
-
456
- private buildEndpoint(action: string, schema: SchemaDefinition, table: TableDefinition): string {
457
- const { method, pathPattern, description } = this.actionMeta(action, schema)
458
- const methodColor = this.methodBadgeColor(method)
459
- const anchor = `${toSnakeCase(schema.name)}-${action}`
460
-
461
- const bodyFields = this.endpointBodyFields(action, schema, table)
462
- const bodySection = bodyFields.length > 0 ? this.buildBodyFieldsTable(bodyFields) : ''
463
-
464
- const exampleRequest = this.buildExampleRequest(method, pathPattern, action, schema, table)
465
- const exampleResponse = this.buildExampleResponse(action, schema, table)
466
-
467
- return ` <div id="${anchor}" class="rounded-lg border border-zinc-200 overflow-hidden">
468
- <div class="flex items-center gap-3 px-4 py-3 bg-zinc-50 border-b border-zinc-200">
469
- <span class="method-badge ${methodColor}">${method}</span>
470
- <code class="text-sm font-mono text-zinc-700">${this.escapeHtml(pathPattern)}</code>
471
- </div>
472
- <div class="px-4 py-4 space-y-4">
473
- <p class="text-sm text-zinc-600">${description}</p>
474
- ${bodySection}
475
- ${exampleRequest}
476
- ${exampleResponse}
477
- </div>
478
- </div>`
479
- }
480
-
481
- // ---------------------------------------------------------------------------
482
- // Body fields table (for store/update endpoints)
483
- // ---------------------------------------------------------------------------
484
-
485
- private buildBodyFieldsTable(
486
- fields: { name: string; type: string; required: boolean; description: string }[]
487
- ): string {
488
- const rows = fields.map(f => {
489
- const req = f.required
490
- ? '<span class="text-rose-500 text-[0.625rem] font-semibold">required</span>'
491
- : '<span class="text-zinc-400 text-[0.625rem]">optional</span>'
492
- return ` <tr class="border-b border-zinc-100 last:border-0">
493
- <td class="py-2 pl-4 pr-3 font-mono text-xs text-emerald-600 whitespace-nowrap">${f.name}</td>
494
- <td class="py-2 pr-3 text-xs text-zinc-500">${f.type}</td>
495
- <td class="py-2 pr-3">${req}</td>
496
- <td class="py-2 pr-4 text-xs text-zinc-400">${f.description}</td>
497
- </tr>`
498
- })
499
-
500
- return ` <div>
501
- <p class="text-xs font-semibold text-zinc-700 mb-2">Request body</p>
502
- <div class="rounded-md border border-zinc-200 overflow-hidden">
503
- <table class="w-full text-sm">
504
- <tbody>
505
- ${rows.join('\n')}
506
- </tbody>
507
- </table>
508
- </div>
509
- </div>`
510
- }
511
-
512
- // ---------------------------------------------------------------------------
513
- // Example request
514
- // ---------------------------------------------------------------------------
515
-
516
- private buildExampleRequest(
517
- method: string,
518
- path: string,
519
- action: string,
520
- schema: SchemaDefinition,
521
- table: TableDefinition
522
- ): string {
523
- const hasBody = action === 'store' || action === 'update'
524
-
525
- if (!hasBody) {
526
- return ` <div>
527
- <p class="text-xs font-semibold text-zinc-700 mb-2">Example request</p>
528
- <div class="rounded-md bg-zinc-900 p-3 overflow-x-auto">
529
- <pre><code class="text-zinc-100 font-mono">curl -X ${method} \\
530
- https://&lt;domain&gt;${this.escapeHtml(path)} \\
531
- -H "Authorization: Bearer &lt;token&gt;"</code></pre>
532
- </div>
533
- </div>`
534
- }
535
-
536
- const payload = this.buildJsonPayload(action, schema, table)
537
-
538
- return ` <div>
539
- <p class="text-xs font-semibold text-zinc-700 mb-2">Example request</p>
540
- <div class="rounded-md bg-zinc-900 p-3 overflow-x-auto">
541
- <pre><code class="text-zinc-100 font-mono">curl -X ${method} \\
542
- https://&lt;domain&gt;${this.escapeHtml(path)} \\
543
- -H "Authorization: Bearer &lt;token&gt;" \\
544
- -H "Content-Type: application/json" \\
545
- -d '${this.escapeHtml(payload)}'</code></pre>
546
- </div>
547
- </div>`
548
- }
549
-
550
- // ---------------------------------------------------------------------------
551
- // Example response
552
- // ---------------------------------------------------------------------------
553
-
554
- private buildExampleResponse(
555
- action: string,
556
- schema: SchemaDefinition,
557
- table: TableDefinition
558
- ): string {
559
- const { statusCode, responseBody } = this.sampleResponse(action, schema, table)
560
-
561
- return ` <div>
562
- <p class="text-xs font-semibold text-zinc-700 mb-2">Example response &mdash; <span class="text-emerald-600">${statusCode}</span></p>
563
- <div class="rounded-md bg-zinc-900 p-3 overflow-x-auto">
564
- <pre><code class="text-zinc-100 font-mono">${this.escapeHtml(responseBody)}</code></pre>
565
- </div>
566
- </div>`
567
- }
568
-
569
- // ---------------------------------------------------------------------------
570
- // Action metadata
571
- // ---------------------------------------------------------------------------
572
-
573
- private actionMeta(
574
- action: string,
575
- schema: SchemaDefinition
576
- ): { method: string; pathPattern: string; description: string } {
577
- const isDependent = DEPENDENT_ARCHETYPES.has(schema.archetype) && !!schema.parents?.length
578
- const isConfig = schema.archetype === Archetype.Configuration
579
- const isEvent = schema.archetype === Archetype.Event
580
- const basePath = this.buildRoutePath(schema)
581
-
582
- switch (action) {
583
- case 'index':
584
- return {
585
- method: 'GET',
586
- pathPattern: basePath,
587
- description: isDependent
588
- ? `List all ${this.displayNamePlural(schema.name)} under the parent.`
589
- : `List all ${this.displayNamePlural(schema.name)}.`,
590
- }
591
- case 'show':
592
- return {
593
- method: 'GET',
594
- pathPattern: isConfig ? basePath : `${basePath}/:id`,
595
- description: isConfig
596
- ? `Retrieve the ${this.displayName(schema.name)} for the parent.`
597
- : `Retrieve a single ${this.displayName(schema.name)} by ID.`,
598
- }
599
- case 'store':
600
- return {
601
- method: 'POST',
602
- pathPattern: basePath,
603
- description: isEvent
604
- ? `Append a new ${this.displayName(schema.name)} event.`
605
- : `Create a new ${this.displayName(schema.name)}.`,
606
- }
607
- case 'update':
608
- return {
609
- method: 'PUT',
610
- pathPattern: isConfig ? basePath : `${basePath}/:id`,
611
- description: isConfig
612
- ? `Create or update the ${this.displayName(schema.name)} for the parent.`
613
- : `Update an existing ${this.displayName(schema.name)}.`,
614
- }
615
- case 'destroy':
616
- return {
617
- method: 'DELETE',
618
- pathPattern: isConfig ? basePath : `${basePath}/:id`,
619
- description: isConfig
620
- ? `Reset the ${this.displayName(schema.name)} to defaults.`
621
- : `Delete a ${this.displayName(schema.name)}.`,
622
- }
623
- default:
624
- return { method: 'GET', pathPattern: basePath, description: '' }
625
- }
626
- }
627
-
628
- // ---------------------------------------------------------------------------
629
- // Body fields for an endpoint
630
- // ---------------------------------------------------------------------------
631
-
632
- private endpointBodyFields(
633
- action: string,
634
- schema: SchemaDefinition,
635
- table: TableDefinition
636
- ): { name: string; type: string; required: boolean; description: string }[] {
637
- if (action !== 'store' && action !== 'update') return []
638
-
639
- const isStore = action === 'store'
640
- const fields: { name: string; type: string; required: boolean; description: string }[] = []
641
-
642
- const parentFkCols = new Set(
643
- (schema.parents ?? []).map(p => `${toSnakeCase(p)}_${toSnakeCase(this.findSchemaPK(p))}`)
644
- )
645
-
646
- for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
647
- if (fieldDef.primaryKey) continue
648
- if (fieldDef.references) {
649
- const refPK = this.findSchemaPK(fieldDef.references)
650
- const fkColName = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
651
- if (parentFkCols.has(fkColName)) continue
652
- if (SYSTEM_COLUMNS.has(fkColName)) continue
653
-
654
- fields.push({
655
- name: toCamelCase(fkColName),
656
- type: 'string',
657
- required: isStore && fieldDef.required,
658
- description: `ID of the referenced ${this.displayName(fieldDef.references)}`,
659
- })
660
- continue
661
- }
662
-
663
- const colName = toSnakeCase(fieldName)
664
- if (SYSTEM_COLUMNS.has(colName)) continue
665
- if (parentFkCols.has(colName)) continue
666
-
667
- fields.push({
668
- name: toCamelCase(fieldName),
669
- type: this.fieldTypeLabel(fieldDef),
670
- required: isStore && fieldDef.required,
671
- description: this.validatorSummary(fieldDef),
672
- })
673
- }
674
-
675
- return fields
676
- }
677
-
678
- // ---------------------------------------------------------------------------
679
- // JSON payloads
680
- // ---------------------------------------------------------------------------
681
-
682
- private buildJsonPayload(
683
- action: string,
684
- schema: SchemaDefinition,
685
- table: TableDefinition
686
- ): string {
687
- const entries: Record<string, unknown> = {}
688
-
689
- const parentFkCols = new Set(
690
- (schema.parents ?? []).map(p => `${toSnakeCase(p)}_${toSnakeCase(this.findSchemaPK(p))}`)
691
- )
692
-
693
- for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
694
- if (fieldDef.primaryKey) continue
695
- if (fieldDef.references) {
696
- const refPK = this.findSchemaPK(fieldDef.references)
697
- const fkColName = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
698
- if (parentFkCols.has(fkColName)) continue
699
- if (SYSTEM_COLUMNS.has(fkColName)) continue
700
- entries[toCamelCase(fkColName)] = '<uuid>'
701
- continue
702
- }
703
-
704
- const colName = toSnakeCase(fieldName)
705
- if (SYSTEM_COLUMNS.has(colName)) continue
706
- if (parentFkCols.has(colName)) continue
707
- if (fieldDef.sensitive) continue
708
-
709
- const camelName = toCamelCase(fieldName)
710
- entries[camelName] = this.sampleValueLiteral(fieldName, fieldDef)
711
- }
712
-
713
- return JSON.stringify(entries, null, 2)
714
- }
715
-
716
- private sampleResponse(
717
- action: string,
718
- schema: SchemaDefinition,
719
- table: TableDefinition
720
- ): { statusCode: number; responseBody: string } {
721
- if (action === 'destroy') {
722
- return { statusCode: 200, responseBody: JSON.stringify({ ok: true }, null, 2) }
723
- }
724
-
725
- const obj: Record<string, unknown> = {}
726
- const pkName = this.findSchemaPK(schema.name)
727
- obj[toCamelCase(pkName)] = this.getPkSample(schema)
728
-
729
- for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
730
- if (fieldDef.primaryKey) continue
731
- const colName = toSnakeCase(fieldName)
732
- if (colName === 'deleted_at') continue
733
-
734
- if (fieldDef.references) {
735
- const refPK = this.findSchemaPK(fieldDef.references)
736
- const fkColName = `${toSnakeCase(fieldName)}_${toSnakeCase(refPK)}`
737
- obj[toCamelCase(fkColName)] = '<uuid>'
738
- continue
739
- }
740
-
741
- const camelName = toCamelCase(fieldName)
742
- if (fieldDef.sensitive) {
743
- obj[camelName] = '[REDACTED]'
744
- continue
745
- }
746
-
747
- if (colName === 'created_at' || colName === 'updated_at') {
748
- obj[camelName] = '2025-01-15T10:30:00.000Z'
749
- continue
750
- }
751
-
752
- obj[camelName] = this.sampleValueLiteral(fieldName, fieldDef)
753
- }
754
-
755
- const statusCode = action === 'store' ? 201 : 200
756
- const body = action === 'index' ? JSON.stringify([obj], null, 2) : JSON.stringify(obj, null, 2)
757
-
758
- return { statusCode, responseBody: body }
759
- }
760
-
761
- // ---------------------------------------------------------------------------
762
- // Route path building (shared with route_generator)
763
- // ---------------------------------------------------------------------------
764
-
765
- private buildRoutePath(schema: SchemaDefinition): string {
766
- const isDependent = DEPENDENT_ARCHETYPES.has(schema.archetype)
767
- const prefix = this.apiConfig.routing === ApiRouting.Prefix ? this.apiConfig.prefix : ''
768
-
769
- const routeParent = schema.parents?.[0]
770
- if (!isDependent || !routeParent) {
771
- return `${prefix}/${toRouteSegment(schema.name)}`
772
- }
773
-
774
- const parentSegment = toRouteSegment(routeParent)
775
- const childSegment = toChildSegment(schema.name, routeParent)
776
-
777
- return `${prefix}/${parentSegment}/:parentId/${childSegment}`
778
- }
779
-
780
- // ---------------------------------------------------------------------------
781
- // Helpers
782
- // ---------------------------------------------------------------------------
783
-
784
- private displayName(name: string): string {
785
- return toPascalCase(name)
786
- .replace(/([A-Z])/g, ' $1')
787
- .trim()
788
- }
789
-
790
- private displayNamePlural(name: string): string {
791
- const display = this.displayName(name).toLowerCase()
792
- const lastWord = display.split(' ').pop() ?? display
793
- const pluralLast = pluralize(lastWord)
794
- const words = display.split(' ')
795
- words[words.length - 1] = pluralLast
796
- return words.join(' ')
797
- }
798
-
799
- private findSchemaPK(schemaName: string): string {
800
- const schema = this.schemaMap.get(schemaName)
801
- if (!schema) return 'id'
802
- for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
803
- if (fieldDef.primaryKey) return fieldName
804
- }
805
- return 'id'
806
- }
807
-
808
- private getPkSample(schema: SchemaDefinition): string {
809
- for (const [, fieldDef] of Object.entries(schema.fields)) {
810
- if (fieldDef.primaryKey) {
811
- if (String(fieldDef.pgType) === 'uuid') return 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
812
- return '1'
813
- }
814
- }
815
- return '1'
816
- }
817
-
818
- private fieldTypeLabel(fieldDef: FieldDefinition): string {
819
- if (isCustomType(fieldDef.pgType)) {
820
- return `enum(${toPascalCase((fieldDef.pgType as PostgreSQLCustomType).name)})`
821
- }
822
- if (fieldDef.enumValues?.length) {
823
- return `enum(${fieldDef.enumValues.join(' | ')})`
824
- }
825
- const pgType = String(fieldDef.pgType)
826
- switch (pgType) {
827
- case 'varchar':
828
- case 'character_varying':
829
- case 'char':
830
- case 'character':
831
- case 'text':
832
- return fieldDef.length ? `string(${fieldDef.length})` : 'string'
833
- case 'uuid':
834
- return 'uuid'
835
- case 'integer':
836
- case 'smallint':
837
- case 'serial':
838
- case 'smallserial':
839
- return 'integer'
840
- case 'bigint':
841
- case 'bigserial':
842
- return 'bigint'
843
- case 'real':
844
- case 'double_precision':
845
- case 'decimal':
846
- case 'numeric':
847
- case 'money':
848
- return fieldDef.precision
849
- ? `decimal(${fieldDef.precision},${fieldDef.scale ?? 0})`
850
- : 'number'
851
- case 'boolean':
852
- return 'boolean'
853
- case 'json':
854
- case 'jsonb':
855
- return 'json'
856
- case 'timestamp':
857
- case 'timestamptz':
858
- case 'timestamp_with_time_zone':
859
- return 'datetime'
860
- case 'date':
861
- return 'date'
862
- case 'time':
863
- case 'timetz':
864
- return 'time'
865
- default:
866
- return pgType
867
- }
868
- }
869
-
870
- private validatorSummary(fieldDef: FieldDefinition): string {
871
- const parts: string[] = []
872
- if (fieldDef.unique) parts.push('unique')
873
- if (fieldDef.length) parts.push(`max ${fieldDef.length} chars`)
874
- for (const v of fieldDef.validators) {
875
- switch (v.type) {
876
- case 'min':
877
- parts.push(`min: ${v.params?.value ?? 0}`)
878
- break
879
- case 'max':
880
- parts.push(`max: ${v.params?.value ?? 0}`)
881
- break
882
- case 'email':
883
- parts.push('email format')
884
- break
885
- case 'url':
886
- parts.push('URL format')
887
- break
888
- case 'regex':
889
- parts.push('pattern')
890
- break
891
- }
892
- }
893
- if (fieldDef.enumValues?.length) {
894
- parts.push(`one of: ${fieldDef.enumValues.join(', ')}`)
895
- }
896
- return parts.join(' &middot; ')
897
- }
898
-
899
- private sampleValueLiteral(fieldName: string, fieldDef: FieldDefinition): unknown {
900
- if (fieldDef.enumValues?.length) return fieldDef.enumValues[0]!
901
-
902
- const pgType = String(fieldDef.pgType)
903
- switch (pgType) {
904
- case 'uuid':
905
- return 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
906
- case 'boolean':
907
- return true
908
- case 'integer':
909
- case 'smallint':
910
- case 'serial':
911
- case 'smallserial':
912
- return 42
913
- case 'bigint':
914
- case 'bigserial':
915
- return 42
916
- case 'real':
917
- case 'double_precision':
918
- case 'decimal':
919
- case 'numeric':
920
- case 'money':
921
- return 9.99
922
- case 'json':
923
- case 'jsonb':
924
- return { key: 'value' }
925
- case 'varchar':
926
- case 'character_varying':
927
- case 'char':
928
- case 'character':
929
- case 'text':
930
- default: {
931
- const snake = toSnakeCase(fieldName)
932
- if (snake === 'email' || snake.endsWith('_email')) return 'user@example.com'
933
- if (
934
- snake === 'url' ||
935
- snake.endsWith('_url') ||
936
- snake === 'website' ||
937
- snake === 'homepage'
938
- )
939
- return 'https://example.com'
940
- const label = toPascalCase(fieldName)
941
- .replace(/([A-Z])/g, ' $1')
942
- .trim()
943
- return `Sample ${label}`
944
- }
945
- }
946
- }
947
-
948
- private archetypeBadgeColor(archetype: Archetype): string {
949
- switch (archetype) {
950
- case Archetype.Entity:
951
- return 'bg-sky-100 text-sky-700'
952
- case Archetype.Contribution:
953
- return 'bg-violet-100 text-violet-700'
954
- case Archetype.Reference:
955
- return 'bg-zinc-100 text-zinc-700'
956
- case Archetype.Attribute:
957
- return 'bg-teal-100 text-teal-700'
958
- case Archetype.Component:
959
- return 'bg-indigo-100 text-indigo-700'
960
- case Archetype.Event:
961
- return 'bg-amber-100 text-amber-700'
962
- case Archetype.Configuration:
963
- return 'bg-rose-100 text-rose-700'
964
- case Archetype.Association:
965
- return 'bg-zinc-100 text-zinc-700'
966
- }
967
- }
968
-
969
- private methodBadgeColor(method: string): string {
970
- switch (method) {
971
- case 'GET':
972
- return 'bg-emerald-500/10 text-emerald-600'
973
- case 'POST':
974
- return 'bg-sky-500/10 text-sky-600'
975
- case 'PUT':
976
- case 'PATCH':
977
- return 'bg-amber-500/10 text-amber-600'
978
- case 'DELETE':
979
- return 'bg-rose-500/10 text-rose-600'
980
- default:
981
- return 'bg-zinc-500/10 text-zinc-600'
982
- }
983
- }
984
-
985
- private escapeHtml(str: string): string {
986
- return str
987
- .replace(/&/g, '&amp;')
988
- .replace(/</g, '&lt;')
989
- .replace(/>/g, '&gt;')
990
- .replace(/"/g, '&quot;')
991
- }
992
- }
993
-
994
- function isCustomType(pgType: unknown): pgType is PostgreSQLCustomType {
995
- return typeof pgType === 'object' && pgType !== null && (pgType as any).type === 'custom'
996
- }