vasp-cli 0.4.0 → 1.0.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 (137) hide show
  1. package/README.md +57 -2
  2. package/dist/vasp +201 -35
  3. package/package.json +2 -2
  4. package/starters/minimal.vasp +1 -1
  5. package/starters/recipe.vasp +11 -20
  6. package/starters/todo-auth-ssr.vasp +33 -20
  7. package/starters/todo.vasp +15 -8
  8. package/templates/shared/.gitignore.hbs +1 -0
  9. package/templates/shared/auth/server/index.hbs +4 -8
  10. package/templates/shared/auth/server/middleware.hbs +33 -15
  11. package/templates/{templates/shared → shared}/auth/server/plugin.hbs +0 -2
  12. package/templates/shared/auth/server/providers/github.hbs +1 -1
  13. package/templates/shared/auth/server/providers/google.hbs +1 -1
  14. package/templates/shared/auth/server/providers/usernameAndPassword.hbs +3 -6
  15. package/templates/shared/bunfig.toml.hbs +3 -0
  16. package/templates/shared/drizzle/schema.hbs +38 -19
  17. package/templates/shared/package.json.hbs +11 -3
  18. package/templates/shared/server/db/client.hbs +19 -1
  19. package/templates/shared/server/db/seed.hbs +16 -0
  20. package/templates/shared/server/index.hbs +47 -0
  21. package/templates/shared/server/middleware/errorHandler.hbs +75 -0
  22. package/templates/shared/server/middleware/logger.hbs +74 -0
  23. package/templates/shared/server/middleware/rateLimit.hbs +2 -2
  24. package/templates/shared/server/routes/_vasp.hbs +37 -0
  25. package/templates/shared/server/routes/actions/_action.hbs +5 -1
  26. package/templates/shared/server/routes/api/_api.hbs +24 -0
  27. package/templates/shared/server/routes/crud/_crud.hbs +58 -10
  28. package/templates/shared/server/routes/queries/_query.hbs +5 -1
  29. package/templates/shared/shared/types.hbs +58 -0
  30. package/templates/shared/shared/validation.hbs +20 -0
  31. package/templates/shared/tests/actions/_action.test.js.hbs +7 -0
  32. package/templates/shared/tests/actions/_action.test.ts.hbs +7 -0
  33. package/templates/shared/tests/auth/login.test.js.hbs +7 -0
  34. package/templates/shared/tests/auth/login.test.ts.hbs +7 -0
  35. package/templates/shared/tests/crud/_entity.test.js.hbs +7 -0
  36. package/templates/shared/tests/crud/_entity.test.ts.hbs +7 -0
  37. package/templates/shared/tests/queries/_query.test.js.hbs +7 -0
  38. package/templates/shared/tests/queries/_query.test.ts.hbs +7 -0
  39. package/templates/shared/tests/setup.js.hbs +5 -0
  40. package/templates/shared/tests/setup.ts.hbs +5 -0
  41. package/templates/shared/tests/vitest.config.js.hbs +8 -0
  42. package/templates/shared/tests/vitest.config.ts.hbs +8 -0
  43. package/templates/shared/tsconfig.json.hbs +2 -1
  44. package/templates/spa/js/src/App.vue.hbs +9 -1
  45. package/templates/spa/js/src/components/VaspErrorBoundary.vue.hbs +33 -0
  46. package/templates/spa/js/src/components/VaspNotifications.vue.hbs +60 -0
  47. package/templates/spa/js/src/vasp/auth.js.hbs +31 -15
  48. package/templates/spa/js/src/vasp/client/actions.js.hbs +7 -1
  49. package/templates/spa/js/src/vasp/client/crud.js.hbs +94 -5
  50. package/templates/spa/js/src/vasp/useVaspNotifications.js.hbs +35 -0
  51. package/templates/spa/js/vite.config.js.hbs +1 -0
  52. package/templates/spa/ts/src/App.vue.hbs +9 -1
  53. package/templates/spa/ts/src/components/VaspErrorBoundary.vue.hbs +33 -0
  54. package/templates/spa/ts/src/components/VaspNotifications.vue.hbs +60 -0
  55. package/templates/spa/ts/src/vasp/auth.ts.hbs +31 -15
  56. package/templates/spa/ts/src/vasp/client/actions.ts.hbs +7 -1
  57. package/templates/spa/ts/src/vasp/client/crud.ts.hbs +96 -10
  58. package/templates/spa/ts/src/vasp/client/types.ts.hbs +14 -28
  59. package/templates/spa/ts/src/vasp/useVaspNotifications.ts.hbs +41 -0
  60. package/templates/spa/ts/vite.config.ts.hbs +1 -0
  61. package/templates/ssr/js/error.vue.hbs +23 -0
  62. package/templates/ssr/js/nuxt.config.js.hbs +1 -0
  63. package/templates/ssr/ts/error.vue.hbs +26 -0
  64. package/templates/ssr/ts/nuxt.config.ts.hbs +1 -0
  65. package/templates/starters/minimal.vasp +15 -0
  66. package/templates/starters/recipe.vasp +70 -0
  67. package/templates/starters/todo-auth-ssr.vasp +65 -0
  68. package/templates/starters/todo.vasp +42 -0
  69. package/templates/templates/shared/.env.example.hbs +0 -14
  70. package/templates/templates/shared/.gitignore.hbs +0 -8
  71. package/templates/templates/shared/auth/client/Login.vue.hbs +0 -46
  72. package/templates/templates/shared/auth/client/Register.vue.hbs +0 -42
  73. package/templates/templates/shared/auth/server/index.hbs +0 -46
  74. package/templates/templates/shared/auth/server/middleware.hbs +0 -33
  75. package/templates/templates/shared/auth/server/providers/github.hbs +0 -48
  76. package/templates/templates/shared/auth/server/providers/google.hbs +0 -53
  77. package/templates/templates/shared/auth/server/providers/usernameAndPassword.hbs +0 -66
  78. package/templates/templates/shared/bunfig.toml.hbs +0 -5
  79. package/templates/templates/shared/drizzle/drizzle.config.hbs +0 -10
  80. package/templates/templates/shared/drizzle/schema.hbs +0 -48
  81. package/templates/templates/shared/jobs/_job.hbs +0 -34
  82. package/templates/templates/shared/jobs/boss.hbs +0 -15
  83. package/templates/templates/shared/package.json.hbs +0 -38
  84. package/templates/templates/shared/server/db/client.hbs +0 -12
  85. package/templates/templates/shared/server/index.hbs +0 -73
  86. package/templates/templates/shared/server/middleware/csrf.hbs +0 -34
  87. package/templates/templates/shared/server/middleware/rateLimit.hbs +0 -44
  88. package/templates/templates/shared/server/routes/actions/_action.hbs +0 -20
  89. package/templates/templates/shared/server/routes/crud/_crud.hbs +0 -86
  90. package/templates/templates/shared/server/routes/jobs/_schedule.hbs +0 -12
  91. package/templates/templates/shared/server/routes/queries/_query.hbs +0 -20
  92. package/templates/templates/shared/server/routes/realtime/_channel.hbs +0 -78
  93. package/templates/templates/shared/server/routes/realtime/index.hbs +0 -9
  94. package/templates/templates/shared/tsconfig.json.hbs +0 -21
  95. package/templates/templates/spa/js/index.html.hbs +0 -12
  96. package/templates/templates/spa/js/src/App.vue.hbs +0 -3
  97. package/templates/templates/spa/js/src/main.js.hbs +0 -9
  98. package/templates/templates/spa/js/src/router/index.js.hbs +0 -41
  99. package/templates/templates/spa/js/src/vasp/auth.js.hbs +0 -45
  100. package/templates/templates/spa/js/src/vasp/client/actions.js.hbs +0 -15
  101. package/templates/templates/spa/js/src/vasp/client/crud.js.hbs +0 -30
  102. package/templates/templates/spa/js/src/vasp/client/index.js.hbs +0 -16
  103. package/templates/templates/spa/js/src/vasp/client/queries.js.hbs +0 -15
  104. package/templates/templates/spa/js/src/vasp/client/realtime.js.hbs +0 -51
  105. package/templates/templates/spa/js/src/vasp/plugin.js.hbs +0 -11
  106. package/templates/templates/spa/js/vite.config.js.hbs +0 -26
  107. package/templates/templates/spa/ts/index.html.hbs +0 -12
  108. package/templates/templates/spa/ts/src/App.vue.hbs +0 -3
  109. package/templates/templates/spa/ts/src/main.ts.hbs +0 -9
  110. package/templates/templates/spa/ts/src/router/index.ts.hbs +0 -41
  111. package/templates/templates/spa/ts/src/vasp/auth.ts.hbs +0 -53
  112. package/templates/templates/spa/ts/src/vasp/client/actions.ts.hbs +0 -19
  113. package/templates/templates/spa/ts/src/vasp/client/crud.ts.hbs +0 -37
  114. package/templates/templates/spa/ts/src/vasp/client/index.ts.hbs +0 -17
  115. package/templates/templates/spa/ts/src/vasp/client/queries.ts.hbs +0 -19
  116. package/templates/templates/spa/ts/src/vasp/client/realtime.ts.hbs +0 -56
  117. package/templates/templates/spa/ts/src/vasp/client/types.ts.hbs +0 -33
  118. package/templates/templates/spa/ts/src/vasp/plugin.ts.hbs +0 -12
  119. package/templates/templates/spa/ts/vite.config.ts.hbs +0 -26
  120. package/templates/templates/ssr/js/_page.vue.hbs +0 -10
  121. package/templates/templates/ssr/js/app.vue.hbs +0 -3
  122. package/templates/templates/ssr/js/composables/useAuth.js.hbs +0 -52
  123. package/templates/templates/ssr/js/composables/useVasp.js.hbs +0 -6
  124. package/templates/templates/ssr/js/middleware/auth.js.hbs +0 -8
  125. package/templates/templates/ssr/js/nuxt.config.js.hbs +0 -15
  126. package/templates/templates/ssr/js/plugins/vasp.client.js.hbs +0 -27
  127. package/templates/templates/ssr/js/plugins/vasp.server.js.hbs +0 -33
  128. package/templates/templates/ssr/ts/_page.vue.hbs +0 -10
  129. package/templates/templates/ssr/ts/app.vue.hbs +0 -3
  130. package/templates/templates/ssr/ts/composables/useAuth.ts.hbs +0 -56
  131. package/templates/templates/ssr/ts/composables/useVasp.ts.hbs +0 -10
  132. package/templates/templates/ssr/ts/middleware/auth.ts.hbs +0 -8
  133. package/templates/templates/ssr/ts/nuxt.config.ts.hbs +0 -19
  134. package/templates/templates/ssr/ts/plugins/vasp.client.ts.hbs +0 -27
  135. package/templates/templates/ssr/ts/plugins/vasp.server.ts.hbs +0 -33
  136. /package/templates/{templates/shared → shared}/.env.hbs +0 -0
  137. /package/templates/{templates/shared → shared}/README.md.hbs +0 -0
@@ -0,0 +1,75 @@
1
+ import { Elysia } from 'elysia'
2
+
3
+ /**
4
+ * Standard error class for Vasp API routes.
5
+ * Throw this from any route handler — the errorHandler middleware will
6
+ * catch it and return a structured `{ ok: false, error: { ... } }` envelope.
7
+ *
8
+ * @example throw new VaspError('NOT_FOUND', 'Recipe not found', 404)
9
+ */
10
+ export class VaspError extends Error {
11
+ constructor(
12
+ code{{#if isTypeScript}}: string{{/if}},
13
+ message{{#if isTypeScript}}: string{{/if}},
14
+ statusCode{{#if isTypeScript}}: number{{/if}} = 400,
15
+ hint{{#if isTypeScript}}?: string{{/if}},
16
+ ) {
17
+ super(message)
18
+ this.name = 'VaspError'
19
+ this.code = code
20
+ this.statusCode = statusCode
21
+ this.hint = hint
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Global error handler + response envelope middleware.
27
+ *
28
+ * - `onError` → catches thrown errors and returns `{ ok: false, error: { code, message, hint? } }`
29
+ * - `onAfterHandle` → wraps successful responses in `{ ok: true, data: ... }`
30
+ */
31
+ export function errorHandler() {
32
+ return new Elysia({ name: 'vasp-error-handler' })
33
+ .onError({ as: 'global' }, ({ code, error, set }) => {
34
+ // VaspError — structured, expected error
35
+ if (error instanceof VaspError) {
36
+ set.status = error.statusCode
37
+ return {
38
+ ok: false,
39
+ error: {
40
+ code: error.code,
41
+ message: error.message,
42
+ ...(error.hint ? { hint: error.hint } : {}),
43
+ },
44
+ }
45
+ }
46
+
47
+ // Elysia built-in NOT_FOUND
48
+ if (code === 'NOT_FOUND') {
49
+ set.status = 404
50
+ return { ok: false, error: { code: 'NOT_FOUND', message: 'Not found' } }
51
+ }
52
+
53
+ // Elysia validation error
54
+ if (code === 'VALIDATION') {
55
+ set.status = 400
56
+ return {
57
+ ok: false,
58
+ error: { code: 'VALIDATION_FAILED', message: error.message },
59
+ }
60
+ }
61
+
62
+ // Unexpected error — log and mask
63
+ console.error('[server error]', error)
64
+ set.status = 500
65
+ return {
66
+ ok: false,
67
+ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' },
68
+ }
69
+ })
70
+ .onAfterHandle({ as: 'global' }, ({ response }) => {
71
+ // Already wrapped (e.g. from onError or explicit envelope)
72
+ if (response && typeof response === 'object' && 'ok' in response) return
73
+ return { ok: true, data: response }
74
+ })
75
+ }
@@ -0,0 +1,74 @@
1
+ import { Elysia } from 'elysia'
2
+ import crypto from 'node:crypto'
3
+
4
+ const isDev = process.env.NODE_ENV !== 'production'
5
+
6
+ const colors{{#if isTypeScript}}: Record<string, string>{{/if}} = {
7
+ GET: '\x1b[32m', // green
8
+ POST: '\x1b[34m', // blue
9
+ PUT: '\x1b[33m', // yellow
10
+ PATCH: '\x1b[33m', // yellow
11
+ DELETE: '\x1b[31m', // red
12
+ reset: '\x1b[0m',
13
+ dim: '\x1b[2m',
14
+ bold: '\x1b[1m',
15
+ }
16
+
17
+ function colorMethod(method{{#if isTypeScript}}: string{{/if}}){{#if isTypeScript}}: string{{/if}} {
18
+ return `${colors[method] ?? ''}${method}${colors.reset}`
19
+ }
20
+
21
+ function statusColor(status{{#if isTypeScript}}: number{{/if}}){{#if isTypeScript}}: string{{/if}} {
22
+ if (status < 300) return '\x1b[32m'
23
+ if (status < 400) return '\x1b[36m'
24
+ if (status < 500) return '\x1b[33m'
25
+ return '\x1b[31m'
26
+ }
27
+
28
+ /**
29
+ * Request tracing & logging middleware.
30
+ *
31
+ * - Assigns a unique `x-request-id` header to every request (UUID v4).
32
+ * - Logs method, path, status, and duration in dev mode.
33
+ * - The request ID is available in handlers via `store.requestId`.
34
+ */
35
+ export function logger() {
36
+ return new Elysia({ name: 'vasp-logger' })
37
+ .derive({ as: 'scoped' }, ({ request }) => {
38
+ const requestId = request.headers.get('x-request-id') || crypto.randomUUID()
39
+ return { requestId, requestStart: performance.now() }
40
+ })
41
+ .onAfterHandle({ as: 'scoped' }, ({ request, requestId, requestStart, set }) => {
42
+ set.headers['x-request-id'] = requestId
43
+
44
+ if (!isDev) return
45
+
46
+ const duration = (performance.now() - requestStart).toFixed(1)
47
+ const url = new URL(request.url)
48
+ const status = typeof set.status === 'number' ? set.status : 200
49
+ const sc = statusColor(status)
50
+ console.log(
51
+ `${colors.dim}[${new Date().toISOString()}]${colors.reset} ` +
52
+ `${colorMethod(request.method)} ${url.pathname} ` +
53
+ `${sc}${status}${colors.reset} ${colors.dim}${duration}ms${colors.reset} ` +
54
+ `${colors.dim}rid:${requestId.slice(0, 8)}${colors.reset}`,
55
+ )
56
+ })
57
+ .onError({ as: 'scoped' }, ({ request, requestId, requestStart, set, error }) => {
58
+ set.headers['x-request-id'] = requestId
59
+
60
+ if (!isDev) return
61
+
62
+ const duration = (performance.now() - requestStart).toFixed(1)
63
+ const url = new URL(request.url)
64
+ const status = typeof set.status === 'number' ? set.status : 500
65
+ const sc = statusColor(status)
66
+ console.log(
67
+ `${colors.dim}[${new Date().toISOString()}]${colors.reset} ` +
68
+ `${colorMethod(request.method)} ${url.pathname} ` +
69
+ `${sc}${status}${colors.reset} ${colors.dim}${duration}ms${colors.reset} ` +
70
+ `${colors.dim}rid:${requestId.slice(0, 8)}${colors.reset} ` +
71
+ `${colors.bold}\x1b[31m${error.message}${colors.reset}`,
72
+ )
73
+ })
74
+ }
@@ -1,4 +1,5 @@
1
1
  import { Elysia } from 'elysia'
2
+ import { VaspError } from './errorHandler.{{ext}}'
2
3
 
3
4
  const MAX_REQUESTS = Number(process.env.RATE_LIMIT_MAX) || 100
4
5
  const WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000
@@ -37,8 +38,7 @@ export function rateLimit() {
37
38
  set.headers['x-ratelimit-remaining'] = String(Math.max(0, MAX_REQUESTS - entry.count))
38
39
 
39
40
  if (entry.count > MAX_REQUESTS) {
40
- set.status = 429
41
- return { error: 'Too many requests. Please try again later.' }
41
+ throw new VaspError('RATE_LIMITED', 'Too many requests. Please try again later.', 429)
42
42
  }
43
43
  })
44
44
  }
@@ -0,0 +1,37 @@
1
+ import { Elysia } from 'elysia'
2
+
3
+ const isDev = process.env.NODE_ENV !== 'production'
4
+
5
+ /**
6
+ * Vasp internal diagnostic routes.
7
+ *
8
+ * GET /api/_vasp/health — always available; returns uptime and version
9
+ * GET /api/_vasp/debug — only in dev (NODE_ENV !== 'production'); returns process info
10
+ */
11
+ export const vaspDiagnosticRoutes = new Elysia({ prefix: '/api/_vasp' })
12
+ .get('/health', ({ set }) => {
13
+ set.headers['cache-control'] = 'no-store'
14
+ return {
15
+ ok: true,
16
+ status: 'healthy',
17
+ version: '{{vaspVersion}}',
18
+ uptime: process.uptime(),
19
+ timestamp: new Date().toISOString(),
20
+ }
21
+ })
22
+ .get('/debug', ({ set }) => {
23
+ if (!isDev) {
24
+ set.status = 404
25
+ return { ok: false, error: { code: 'NOT_FOUND', message: 'Not found' } }
26
+ }
27
+
28
+ set.headers['cache-control'] = 'no-store'
29
+ return {
30
+ ok: true,
31
+ env: process.env.NODE_ENV ?? 'unknown',
32
+ nodeVersion: process.version,
33
+ memoryMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
34
+ pid: process.pid,
35
+ cwd: process.cwd(),
36
+ }
37
+ })
@@ -1,13 +1,17 @@
1
1
  import { Elysia } from 'elysia'
2
2
  import { db } from '../../db/client.{{ext}}'
3
3
  {{#if requiresAuth}}
4
- import { requireAuth } from '../../auth/middleware.{{ext}}'
4
+ import { requireAuth{{#if hasRoles}}, requireRole{{/if}} } from '../../auth/middleware.{{ext}}'
5
5
  {{/if}}
6
6
  import { {{namedExport}} } from '{{importPath fnSource ext}}'
7
7
 
8
8
  export const {{camelCase name}}Route = new Elysia()
9
9
  {{#if requiresAuth}}
10
+ {{#if hasRoles}}
11
+ .use(requireRole([{{#each roles}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]))
12
+ {{else}}
10
13
  .use(requireAuth)
14
+ {{/if}}
11
15
  .post('/api/actions/{{camelCase name}}', async ({ body, user }) => {
12
16
  const result = await {{namedExport}}({ db, user, args: body })
13
17
  return result
@@ -0,0 +1,24 @@
1
+ import { Elysia } from 'elysia'
2
+ import { db } from '../../db/client.{{ext}}'
3
+ {{#if requiresAuth}}
4
+ import { requireAuth{{#if hasRoles}}, requireRole{{/if}} } from '../../auth/middleware.{{ext}}'
5
+ {{/if}}
6
+ import { {{namedExport}} } from '{{importPath fnSource ext}}'
7
+
8
+ export const {{camelCase name}}ApiRoute = new Elysia()
9
+ {{#if requiresAuth}}
10
+ {{#if hasRoles}}
11
+ .use(requireRole([{{#each roles}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]))
12
+ {{else}}
13
+ .use(requireAuth)
14
+ {{/if}}
15
+ .{{lowerCase method}}('{{path}}', async ({ params, query, body, request, user, set }) => {
16
+ const result = await {{namedExport}}({ db, user, args: { params, query, body, request, set } })
17
+ return result
18
+ })
19
+ {{else}}
20
+ .{{lowerCase method}}('{{path}}', async ({ params, query, body, request, set }) => {
21
+ const result = await {{namedExport}}({ db, args: { params, query, body, request, set } })
22
+ return result
23
+ })
24
+ {{/if}}
@@ -1,7 +1,10 @@
1
1
  import { Elysia, t } from 'elysia'
2
2
  import { db } from '../../db/client.{{ext}}'
3
- import { eq, sql, asc, desc, and, ilike } from 'drizzle-orm'
3
+ import { safeParse } from 'valibot'
4
+ import { eq, sql, asc, desc, and } from 'drizzle-orm'
4
5
  import { {{camelCase entity}}s } from '../../../drizzle/schema.{{ext}}'
6
+ import { Create{{pascalCase entity}}Schema, Update{{pascalCase entity}}Schema } from '../../../shared/validation.{{ext}}'
7
+ import { VaspError } from '../../middleware/errorHandler.{{ext}}'
5
8
  {{#if hasAuth}}
6
9
  import { requireAuth } from '../../auth/middleware.{{ext}}'
7
10
  {{/if}}
@@ -18,6 +21,19 @@ export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{
18
21
  const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 100)
19
22
  const offset = Math.max(Number(query.offset) || 0, 0)
20
23
 
24
+ {{#if hasRelations}}
25
+ const items = await db.query.{{camelCase entity}}s.findMany({
26
+ with: {
27
+ {{#each withRelations}}
28
+ {{camelCase name}}: true,
29
+ {{/each}}
30
+ },
31
+ limit,
32
+ offset,
33
+ })
34
+ const [countResult] = await db.select({ count: sql`count(*)::int` }).from({{camelCase entity}}s)
35
+ return { items, total: countResult?.count ?? 0, limit, offset }
36
+ {{else}}
21
37
  const table = {{camelCase entity}}s
22
38
 
23
39
  // Multi-column sorting: orderBy=col1,col2 & dir=asc,desc
@@ -47,27 +63,59 @@ export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{
47
63
  where ? countQuery.where(where) : countQuery,
48
64
  ])
49
65
 
50
- return { data, total: countResult[0]?.count ?? 0, limit, offset }
66
+ return { items: data, total: countResult[0]?.count ?? 0, limit, offset }
67
+ {{/if}}
51
68
  })
52
69
  {{/if}}
53
70
  {{#if (includes operations "create")}}
54
71
  .post('/', async ({ body }) => {
55
- const [created] = await db.insert({{camelCase entity}}s).values(body).returning()
72
+ const parsed = safeParse(Create{{pascalCase entity}}Schema, body)
73
+ if (!parsed.success) {
74
+ const firstIssue = parsed.issues?.[0]
75
+ throw new VaspError(
76
+ 'VALIDATION_FAILED',
77
+ firstIssue?.message ?? 'Invalid request payload',
78
+ 400,
79
+ )
80
+ }
81
+
82
+ const [created] = await db.insert({{camelCase entity}}s).values(parsed.output).returning()
56
83
  {{#if hasRealtime}}
57
84
  publish{{pascalCase entity}}('created', created)
58
85
  {{/if}}
59
86
  return created
60
87
  })
61
88
  {{/if}}
62
- .get('/:id', async ({ params: { id }, set }) => {
89
+ .get('/:id', async ({ params: { id } }) => {
90
+ {{#if hasRelations}}
91
+ const item = await db.query.{{camelCase entity}}s.findFirst({
92
+ where: (t, { eq }) => eq(t.id, Number(id)),
93
+ with: {
94
+ {{#each withRelations}}
95
+ {{camelCase name}}: true,
96
+ {{/each}}
97
+ },
98
+ })
99
+ {{else}}
63
100
  const [item] = await db.select().from({{camelCase entity}}s).where(eq({{camelCase entity}}s.id, Number(id))).limit(1)
64
- if (!item) { set.status = 404; return { error: 'Not found' } }
101
+ {{/if}}
102
+ if (!item) throw new VaspError('NOT_FOUND', '{{pascalCase entity}} not found', 404)
65
103
  return item
66
104
  })
67
105
  {{#if (includes operations "update")}}
68
- .put('/:id', async ({ params: { id }, body, set }) => {
69
- const [updated] = await db.update({{camelCase entity}}s).set(body).where(eq({{camelCase entity}}s.id, Number(id))).returning()
70
- if (!updated) { set.status = 404; return { error: 'Not found' } }
106
+ .put('/:id', async ({ params: { id }, body }) => {
107
+ const parsed = safeParse(Update{{pascalCase entity}}Schema, body)
108
+ if (!parsed.success) {
109
+ const firstIssue = parsed.issues?.[0]
110
+ throw new VaspError(
111
+ 'VALIDATION_FAILED',
112
+ firstIssue?.message ?? 'Invalid request payload',
113
+ 400,
114
+ )
115
+ }
116
+
117
+ const [updated] = await db.update({{camelCase entity}}s).set(parsed.output).where(eq({{camelCase entity}}s.id, Number(id))).returning()
118
+ if (!updated) throw new VaspError('NOT_FOUND', '{{pascalCase entity}} not found', 404)
71
119
  {{#if hasRealtime}}
72
120
  publish{{pascalCase entity}}('updated', updated)
73
121
  {{/if}}
@@ -75,9 +123,9 @@ export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{
75
123
  })
76
124
  {{/if}}
77
125
  {{#if (includes operations "delete")}}
78
- .delete('/:id', async ({ params: { id }, set }) => {
126
+ .delete('/:id', async ({ params: { id } }) => {
79
127
  const [deleted] = await db.delete({{camelCase entity}}s).where(eq({{camelCase entity}}s.id, Number(id))).returning()
80
- if (!deleted) { set.status = 404; return { error: 'Not found' } }
128
+ if (!deleted) throw new VaspError('NOT_FOUND', '{{pascalCase entity}} not found', 404)
81
129
  {{#if hasRealtime}}
82
130
  publish{{pascalCase entity}}('deleted', deleted)
83
131
  {{/if}}
@@ -1,13 +1,17 @@
1
1
  import { Elysia } from 'elysia'
2
2
  import { db } from '../../db/client.{{ext}}'
3
3
  {{#if requiresAuth}}
4
- import { requireAuth } from '../../auth/middleware.{{ext}}'
4
+ import { requireAuth{{#if hasRoles}}, requireRole{{/if}} } from '../../auth/middleware.{{ext}}'
5
5
  {{/if}}
6
6
  import { {{namedExport}} } from '{{importPath fnSource ext}}'
7
7
 
8
8
  export const {{camelCase name}}Route = new Elysia()
9
9
  {{#if requiresAuth}}
10
+ {{#if hasRoles}}
11
+ .use(requireRole([{{#each roles}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]))
12
+ {{else}}
10
13
  .use(requireAuth)
14
+ {{/if}}
11
15
  .get('/api/queries/{{camelCase name}}', async ({ query, user }) => {
12
16
  const result = await {{namedExport}}({ db, user, args: query })
13
17
  return result
@@ -0,0 +1,58 @@
1
+ // Auto-generated by Vasp — entity interfaces derived from .vasp schema
2
+ // Re-run `vasp build` or restart `vasp start` to regenerate
3
+
4
+ {{#each entities}}
5
+ export interface {{pascalCase name}} {
6
+ {{#each fields}}
7
+ {{#if isRelation}}
8
+ {{#if isArray}}
9
+ {{name}}: {{pascalCase relatedEntity}}[]
10
+ {{else}}
11
+ {{name}}: {{pascalCase relatedEntity}}{{#if nullable}} | null{{/if}}
12
+ {{name}}Id: number{{#if nullable}} | null{{/if}}
13
+ {{/if}}
14
+ {{else}}
15
+ {{name}}: {{tsFieldType type enumValues}}{{#if nullable}} | null{{/if}}
16
+ {{/if}}
17
+ {{/each}}
18
+ createdAt: Date
19
+ updatedAt: Date
20
+ }
21
+
22
+ export interface Create{{pascalCase name}}Input {
23
+ {{#each fields}}
24
+ {{#if isRelation}}
25
+ {{#unless isArray}}
26
+ {{name}}Id{{#if nullable}}?{{/if}}: number
27
+ {{/unless}}
28
+ {{else}}
29
+ {{#unless (includes modifiers "id")}}
30
+ {{name}}{{#if nullable}}?{{/if}}: {{tsFieldType type enumValues}}{{#if nullable}} | null{{/if}}
31
+ {{/unless}}
32
+ {{/if}}
33
+ {{/each}}
34
+ }
35
+
36
+ export interface Update{{pascalCase name}}Input {
37
+ {{#each fields}}
38
+ {{#if isRelation}}
39
+ {{#unless isArray}}
40
+ {{name}}Id?: number{{#if nullable}} | null{{/if}}
41
+ {{/unless}}
42
+ {{else}}
43
+ {{#unless (includes modifiers "id")}}
44
+ {{name}}?: {{tsFieldType type enumValues}}{{#if nullable}} | null{{/if}}
45
+ {{/unless}}
46
+ {{/if}}
47
+ {{/each}}
48
+ }
49
+
50
+ {{/each}}
51
+ {{#each queries}}
52
+ export type {{pascalCase name}}Args = Record<string, unknown>
53
+ export type {{pascalCase name}}Return = unknown
54
+ {{/each}}
55
+ {{#each actions}}
56
+ export type {{pascalCase name}}Args = Record<string, unknown>
57
+ export type {{pascalCase name}}Return = unknown
58
+ {{/each}}
@@ -0,0 +1,20 @@
1
+ import * as v from 'valibot'
2
+
3
+ {{#each entities}}
4
+ export const Create{{pascalCase name}}Schema = v.object({
5
+ {{#each fields}}
6
+ {{#if isRelation}}
7
+ {{#unless isArray}}
8
+ {{name}}Id: {{valibotSchema "Int" nullable true}},
9
+ {{/unless}}
10
+ {{else}}
11
+ {{#unless (includes modifiers "id")}}
12
+ {{name}}: {{valibotSchema type nullable true enumValues}},
13
+ {{/unless}}
14
+ {{/if}}
15
+ {{/each}}
16
+ })
17
+
18
+ export const Update{{pascalCase name}}Schema = v.partial(Create{{pascalCase name}}Schema)
19
+
20
+ {{/each}}
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ describe('{{pascalCase name}} action', () => {
4
+ it('adds generated action test scaffold', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ describe('{{pascalCase name}} action', () => {
4
+ it('adds generated action test scaffold', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ describe('Auth login flow', () => {
4
+ it('adds generated auth test scaffold', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ describe('Auth login flow', () => {
4
+ it('adds generated auth test scaffold', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ describe('{{pascalCase entity}} CRUD', () => {
4
+ it('adds generated CRUD test scaffold', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ describe('{{pascalCase entity}} CRUD', () => {
4
+ it('adds generated CRUD test scaffold', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ describe('{{pascalCase name}} query', () => {
4
+ it('adds generated query test scaffold', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ describe('{{pascalCase name}} query', () => {
4
+ it('adds generated query test scaffold', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,5 @@
1
+ import { beforeEach } from 'vitest'
2
+
3
+ beforeEach(() => {
4
+ // Place global test setup here.
5
+ })
@@ -0,0 +1,5 @@
1
+ import { beforeEach } from 'vitest'
2
+
3
+ beforeEach(() => {
4
+ // Place global test setup here.
5
+ })
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['tests/**/*.test.js'],
6
+ setupFiles: ['tests/setup.js'],
7
+ },
8
+ })
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['tests/**/*.test.ts'],
6
+ setupFiles: ['tests/setup.ts'],
7
+ },
8
+ })
@@ -13,9 +13,10 @@
13
13
  "jsx": "preserve",
14
14
  "paths": {
15
15
  "@src/*": ["./src/*"],
16
+ "@shared/*": ["./shared/*"],
16
17
  "@vasp-framework/client": ["./src/vasp/client/index.ts"]
17
18
  }
18
19
  },
19
- "include": ["src/**/*.ts", "src/**/*.vue", "server/**/*.ts", "drizzle/**/*.ts"],
20
+ "include": ["src/**/*.ts", "src/**/*.vue", "server/**/*.ts", "shared/**/*.ts", "drizzle/**/*.ts"],
20
21
  "exclude": ["node_modules", "dist", ".vasp-gen"]
21
22
  }
@@ -1,3 +1,11 @@
1
+ <script setup>
2
+ import VaspErrorBoundary from './components/VaspErrorBoundary.vue'
3
+ import VaspNotifications from './components/VaspNotifications.vue'
4
+ </script>
5
+
1
6
  <template>
2
- <RouterView />
7
+ <VaspErrorBoundary>
8
+ <RouterView />
9
+ </VaspErrorBoundary>
10
+ <VaspNotifications />
3
11
  </template>
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ import { onErrorCaptured, ref } from 'vue'
3
+
4
+ const hasError = ref(false)
5
+ const message = ref('Something went wrong')
6
+
7
+ onErrorCaptured((error) => {
8
+ hasError.value = true
9
+ message.value = import.meta.env.DEV && error instanceof Error
10
+ ? error.message
11
+ : 'Something went wrong'
12
+ return false
13
+ })
14
+ </script>
15
+
16
+ <template>
17
+ <div v-if="hasError" class="vasp-error-boundary">
18
+ <h2>Unexpected error</h2>
19
+ <p>{{ message }}</p>
20
+ </div>
21
+ <slot v-else />
22
+ </template>
23
+
24
+ <style scoped>
25
+ .vasp-error-boundary {
26
+ margin: 24px;
27
+ padding: 16px;
28
+ border: 1px solid #ef4444;
29
+ border-radius: 8px;
30
+ background: #fef2f2;
31
+ color: #991b1b;
32
+ }
33
+ </style>