vasp-cli 0.3.1 → 0.9.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 (140) hide show
  1. package/dist/vasp +80 -31
  2. package/package.json +2 -2
  3. package/starters/minimal.vasp +1 -1
  4. package/starters/recipe.vasp +70 -0
  5. package/starters/todo-auth-ssr.vasp +33 -20
  6. package/starters/todo.vasp +15 -8
  7. package/templates/shared/.gitignore.hbs +1 -0
  8. package/templates/shared/README.md.hbs +53 -0
  9. package/templates/shared/auth/client/Login.vue.hbs +1 -1
  10. package/templates/shared/auth/client/Register.vue.hbs +1 -1
  11. package/templates/shared/auth/server/index.hbs +4 -8
  12. package/templates/shared/auth/server/middleware.hbs +33 -15
  13. package/templates/shared/auth/server/plugin.hbs +7 -0
  14. package/templates/shared/auth/server/providers/github.hbs +1 -1
  15. package/templates/shared/auth/server/providers/google.hbs +1 -1
  16. package/templates/shared/auth/server/providers/usernameAndPassword.hbs +3 -6
  17. package/templates/shared/bunfig.toml.hbs +3 -0
  18. package/templates/shared/drizzle/schema.hbs +39 -9
  19. package/templates/shared/jobs/_job.hbs +12 -2
  20. package/templates/shared/package.json.hbs +14 -4
  21. package/templates/shared/server/db/client.hbs +19 -1
  22. package/templates/shared/server/db/seed.hbs +16 -0
  23. package/templates/shared/server/index.hbs +48 -0
  24. package/templates/shared/server/middleware/errorHandler.hbs +75 -0
  25. package/templates/shared/server/middleware/logger.hbs +74 -0
  26. package/templates/{templates/shared → shared}/server/middleware/rateLimit.hbs +2 -2
  27. package/templates/shared/server/routes/_vasp.hbs +37 -0
  28. package/templates/shared/server/routes/actions/_action.hbs +5 -1
  29. package/templates/shared/server/routes/api/_api.hbs +24 -0
  30. package/templates/shared/server/routes/crud/_crud.hbs +103 -11
  31. package/templates/shared/server/routes/queries/_query.hbs +5 -1
  32. package/templates/shared/server/routes/realtime/_channel.hbs +58 -10
  33. package/templates/shared/shared/types.hbs +58 -0
  34. package/templates/shared/shared/validation.hbs +20 -0
  35. package/templates/shared/tests/actions/_action.test.js.hbs +7 -0
  36. package/templates/shared/tests/actions/_action.test.ts.hbs +7 -0
  37. package/templates/shared/tests/auth/login.test.js.hbs +7 -0
  38. package/templates/shared/tests/auth/login.test.ts.hbs +7 -0
  39. package/templates/shared/tests/crud/_entity.test.js.hbs +7 -0
  40. package/templates/shared/tests/crud/_entity.test.ts.hbs +7 -0
  41. package/templates/shared/tests/queries/_query.test.js.hbs +7 -0
  42. package/templates/shared/tests/queries/_query.test.ts.hbs +7 -0
  43. package/templates/shared/tests/setup.js.hbs +5 -0
  44. package/templates/shared/tests/setup.ts.hbs +5 -0
  45. package/templates/shared/tests/vitest.config.js.hbs +8 -0
  46. package/templates/shared/tests/vitest.config.ts.hbs +8 -0
  47. package/templates/shared/tsconfig.json.hbs +2 -1
  48. package/templates/spa/js/src/App.vue.hbs +9 -1
  49. package/templates/spa/js/src/components/VaspErrorBoundary.vue.hbs +33 -0
  50. package/templates/spa/js/src/components/VaspNotifications.vue.hbs +60 -0
  51. package/templates/spa/js/src/vasp/auth.js.hbs +31 -15
  52. package/templates/spa/js/src/vasp/client/actions.js.hbs +7 -1
  53. package/templates/spa/js/src/vasp/client/crud.js.hbs +94 -5
  54. package/templates/spa/js/src/vasp/useVaspNotifications.js.hbs +35 -0
  55. package/templates/spa/js/vite.config.js.hbs +1 -0
  56. package/templates/spa/ts/src/App.vue.hbs +9 -1
  57. package/templates/spa/ts/src/components/VaspErrorBoundary.vue.hbs +33 -0
  58. package/templates/spa/ts/src/components/VaspNotifications.vue.hbs +60 -0
  59. package/templates/spa/ts/src/vasp/auth.ts.hbs +31 -15
  60. package/templates/spa/ts/src/vasp/client/actions.ts.hbs +7 -1
  61. package/templates/spa/ts/src/vasp/client/crud.ts.hbs +96 -10
  62. package/templates/spa/ts/src/vasp/client/types.ts.hbs +14 -28
  63. package/templates/spa/ts/src/vasp/useVaspNotifications.ts.hbs +41 -0
  64. package/templates/spa/ts/vite.config.ts.hbs +1 -0
  65. package/templates/ssr/js/error.vue.hbs +23 -0
  66. package/templates/ssr/js/nuxt.config.js.hbs +1 -0
  67. package/templates/ssr/js/plugins/vasp.client.js.hbs +11 -1
  68. package/templates/ssr/ts/error.vue.hbs +26 -0
  69. package/templates/ssr/ts/nuxt.config.ts.hbs +1 -0
  70. package/templates/ssr/ts/plugins/vasp.client.ts.hbs +11 -1
  71. package/templates/starters/minimal.vasp +15 -0
  72. package/templates/starters/recipe.vasp +70 -0
  73. package/templates/starters/todo-auth-ssr.vasp +65 -0
  74. package/templates/starters/todo.vasp +42 -0
  75. package/templates/templates/shared/.gitignore.hbs +0 -8
  76. package/templates/templates/shared/auth/client/Login.vue.hbs +0 -46
  77. package/templates/templates/shared/auth/client/Register.vue.hbs +0 -42
  78. package/templates/templates/shared/auth/server/index.hbs +0 -51
  79. package/templates/templates/shared/auth/server/middleware.hbs +0 -33
  80. package/templates/templates/shared/auth/server/providers/github.hbs +0 -48
  81. package/templates/templates/shared/auth/server/providers/google.hbs +0 -53
  82. package/templates/templates/shared/auth/server/providers/usernameAndPassword.hbs +0 -69
  83. package/templates/templates/shared/bunfig.toml.hbs +0 -2
  84. package/templates/templates/shared/drizzle/schema.hbs +0 -48
  85. package/templates/templates/shared/jobs/_job.hbs +0 -34
  86. package/templates/templates/shared/jobs/boss.hbs +0 -15
  87. package/templates/templates/shared/package.json.hbs +0 -35
  88. package/templates/templates/shared/server/db/client.hbs +0 -12
  89. package/templates/templates/shared/server/index.hbs +0 -60
  90. package/templates/templates/shared/server/routes/actions/_action.hbs +0 -20
  91. package/templates/templates/shared/server/routes/crud/_crud.hbs +0 -86
  92. package/templates/templates/shared/server/routes/jobs/_schedule.hbs +0 -12
  93. package/templates/templates/shared/server/routes/queries/_query.hbs +0 -20
  94. package/templates/templates/shared/server/routes/realtime/_channel.hbs +0 -78
  95. package/templates/templates/shared/server/routes/realtime/index.hbs +0 -9
  96. package/templates/templates/shared/tsconfig.json.hbs +0 -21
  97. package/templates/templates/spa/js/index.html.hbs +0 -12
  98. package/templates/templates/spa/js/src/App.vue.hbs +0 -3
  99. package/templates/templates/spa/js/src/main.js.hbs +0 -9
  100. package/templates/templates/spa/js/src/router/index.js.hbs +0 -41
  101. package/templates/templates/spa/js/src/vasp/auth.js.hbs +0 -45
  102. package/templates/templates/spa/js/src/vasp/client/actions.js.hbs +0 -15
  103. package/templates/templates/spa/js/src/vasp/client/crud.js.hbs +0 -30
  104. package/templates/templates/spa/js/src/vasp/client/index.js.hbs +0 -16
  105. package/templates/templates/spa/js/src/vasp/client/queries.js.hbs +0 -15
  106. package/templates/templates/spa/js/src/vasp/client/realtime.js.hbs +0 -51
  107. package/templates/templates/spa/js/src/vasp/plugin.js.hbs +0 -11
  108. package/templates/templates/spa/js/vite.config.js.hbs +0 -26
  109. package/templates/templates/spa/ts/index.html.hbs +0 -12
  110. package/templates/templates/spa/ts/src/App.vue.hbs +0 -3
  111. package/templates/templates/spa/ts/src/main.ts.hbs +0 -9
  112. package/templates/templates/spa/ts/src/router/index.ts.hbs +0 -41
  113. package/templates/templates/spa/ts/src/vasp/auth.ts.hbs +0 -53
  114. package/templates/templates/spa/ts/src/vasp/client/actions.ts.hbs +0 -19
  115. package/templates/templates/spa/ts/src/vasp/client/crud.ts.hbs +0 -37
  116. package/templates/templates/spa/ts/src/vasp/client/index.ts.hbs +0 -17
  117. package/templates/templates/spa/ts/src/vasp/client/queries.ts.hbs +0 -19
  118. package/templates/templates/spa/ts/src/vasp/client/realtime.ts.hbs +0 -56
  119. package/templates/templates/spa/ts/src/vasp/client/types.ts.hbs +0 -33
  120. package/templates/templates/spa/ts/src/vasp/plugin.ts.hbs +0 -12
  121. package/templates/templates/spa/ts/vite.config.ts.hbs +0 -26
  122. package/templates/templates/ssr/js/_page.vue.hbs +0 -10
  123. package/templates/templates/ssr/js/app.vue.hbs +0 -3
  124. package/templates/templates/ssr/js/composables/useAuth.js.hbs +0 -52
  125. package/templates/templates/ssr/js/composables/useVasp.js.hbs +0 -6
  126. package/templates/templates/ssr/js/middleware/auth.js.hbs +0 -8
  127. package/templates/templates/ssr/js/nuxt.config.js.hbs +0 -15
  128. package/templates/templates/ssr/js/plugins/vasp.client.js.hbs +0 -27
  129. package/templates/templates/ssr/js/plugins/vasp.server.js.hbs +0 -33
  130. package/templates/templates/ssr/ts/_page.vue.hbs +0 -10
  131. package/templates/templates/ssr/ts/app.vue.hbs +0 -3
  132. package/templates/templates/ssr/ts/composables/useAuth.ts.hbs +0 -56
  133. package/templates/templates/ssr/ts/composables/useVasp.ts.hbs +0 -10
  134. package/templates/templates/ssr/ts/middleware/auth.ts.hbs +0 -8
  135. package/templates/templates/ssr/ts/nuxt.config.ts.hbs +0 -19
  136. package/templates/templates/ssr/ts/plugins/vasp.client.ts.hbs +0 -27
  137. package/templates/templates/ssr/ts/plugins/vasp.server.ts.hbs +0 -33
  138. /package/templates/{templates/shared/.env.example.hbs → shared/.env.hbs} +0 -0
  139. /package/templates/{templates/shared → shared}/drizzle/drizzle.config.hbs +0 -0
  140. /package/templates/{templates/shared → shared}/server/middleware/csrf.hbs +0 -0
@@ -2,15 +2,34 @@ import { Elysia } from 'elysia'
2
2
  import { cors } from '@elysiajs/cors'
3
3
  import { staticPlugin } from '@elysiajs/static'
4
4
  import { db } from './db/client.{{ext}}'
5
+ import { logger } from './middleware/logger.{{ext}}'
6
+ import { rateLimit } from './middleware/rateLimit.{{ext}}'
7
+ import { errorHandler } from './middleware/errorHandler.{{ext}}'
8
+ import { vaspDiagnosticRoutes } from './routes/_vasp.{{ext}}'
9
+ {{#if isSsr}}
10
+ import { csrfProtection } from './middleware/csrf.{{ext}}'
11
+ {{/if}}
5
12
  {{#if hasAuth}}
6
13
  import { authRoutes } from './auth/index.{{ext}}'
7
14
  {{/if}}
15
+ {{#each middlewares}}
16
+ {{#if (eq scope "global")}}
17
+ {{#if (eq fn.kind "default")}}
18
+ import {{importAlias}} from '{{importPath fnSource ../ext}}'
19
+ {{else}}
20
+ import { {{importName fn}} as {{importAlias}} } from '{{importPath fnSource ../ext}}'
21
+ {{/if}}
22
+ {{/if}}
23
+ {{/each}}
8
24
  {{#each queries}}
9
25
  import { {{camelCase name}}Route } from './routes/queries/{{camelCase name}}.{{../ext}}'
10
26
  {{/each}}
11
27
  {{#each actions}}
12
28
  import { {{camelCase name}}Route } from './routes/actions/{{camelCase name}}.{{../ext}}'
13
29
  {{/each}}
30
+ {{#each apis}}
31
+ import { {{camelCase name}}ApiRoute } from './routes/api/{{camelCase name}}.{{../ext}}'
32
+ {{/each}}
14
33
  {{#each cruds}}
15
34
  import { {{camelCase entity}}CrudRoutes } from './routes/crud/{{camelCase entity}}.{{../ext}}'
16
35
  {{/each}}
@@ -23,21 +42,50 @@ import { {{camelCase name}}ScheduleRoute } from './routes/jobs/{{camelCase name}
23
42
 
24
43
  const PORT = Number(process.env.PORT) || {{backendPort}}
25
44
 
45
+ const REQUIRED_ENV_VARS = [{{#each requiredEnvVars}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]
46
+ const missingEnvVars = REQUIRED_ENV_VARS.filter((name) => {
47
+ const value = process.env[name]
48
+ return typeof value !== 'string' || value.trim() === ''
49
+ })
50
+
51
+ if (missingEnvVars.length > 0) {
52
+ console.error('❌ Missing required environment variables:')
53
+ for (const key of missingEnvVars) {
54
+ console.error(` - ${key}`)
55
+ }
56
+ process.exit(1)
57
+ }
58
+
26
59
  const app = new Elysia()
60
+ .use(logger())
61
+ .use(errorHandler())
27
62
  .use(cors({
28
63
  origin: process.env.CORS_ORIGIN || 'http://localhost:{{frontendPort}}',
29
64
  credentials: true,
30
65
  }))
66
+ .use(rateLimit())
67
+ {{#if isSsr}}
68
+ .use(csrfProtection())
69
+ {{/if}}
31
70
  .get('/api/health', () => ({ status: 'ok', version: '{{vaspVersion}}' }))
71
+ .use(vaspDiagnosticRoutes)
32
72
  {{#if hasAuth}}
33
73
  .use(authRoutes)
34
74
  {{/if}}
75
+ {{#each middlewares}}
76
+ {{#if (eq scope "global")}}
77
+ .use({{importAlias}})
78
+ {{/if}}
79
+ {{/each}}
35
80
  {{#each queries}}
36
81
  .use({{camelCase name}}Route)
37
82
  {{/each}}
38
83
  {{#each actions}}
39
84
  .use({{camelCase name}}Route)
40
85
  {{/each}}
86
+ {{#each apis}}
87
+ .use({{camelCase name}}ApiRoute)
88
+ {{/each}}
41
89
  {{#each cruds}}
42
90
  .use({{camelCase entity}}CrudRoutes)
43
91
  {{/each}}
@@ -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,42 +1,134 @@
1
1
  import { Elysia, t } from 'elysia'
2
2
  import { db } from '../../db/client.{{ext}}'
3
- import { eq } 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}}
11
+ {{#if hasRealtime}}
12
+ import { publish{{pascalCase entity}} } from '../realtime/{{camelCase realtimeName}}.{{ext}}'
13
+ {{/if}}
8
14
 
9
15
  export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{camelCase entity}}' })
10
16
  {{#if hasAuth}}
11
17
  .use(requireAuth)
12
18
  {{/if}}
13
19
  {{#if (includes operations "list")}}
14
- .get('/', async () => {
15
- return db.select().from({{camelCase entity}}s)
20
+ .get('/', async ({ query }) => {
21
+ const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 100)
22
+ const offset = Math.max(Number(query.offset) || 0, 0)
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}}
37
+ const table = {{camelCase entity}}s
38
+
39
+ // Multi-column sorting: orderBy=col1,col2 & dir=asc,desc
40
+ const orderByFields = (query.orderBy ?? 'id').split(',')
41
+ const directions = (query.dir ?? 'asc').split(',')
42
+ const orderClauses = orderByFields.map((field, i) => {
43
+ const col = table[field.trim()] ?? table.id
44
+ const dirFn = (directions[i] ?? directions[0] ?? 'asc').trim() === 'desc' ? desc : asc
45
+ return dirFn(col)
46
+ })
47
+
48
+ // Build WHERE conditions from filter.* query params
49
+ const conditions = []
50
+ for (const [key, value] of Object.entries(query)) {
51
+ if (!key.startsWith('filter.')) continue
52
+ const field = key.slice(7)
53
+ if (!table[field]) continue
54
+ conditions.push(eq(table[field], value))
55
+ }
56
+ const where = conditions.length > 0 ? and(...conditions) : undefined
57
+
58
+ const baseQuery = db.select().from(table)
59
+ const countQuery = db.select({ count: sql`count(*)::int` }).from(table)
60
+
61
+ const [data, countResult] = await Promise.all([
62
+ (where ? baseQuery.where(where) : baseQuery).orderBy(...orderClauses).limit(limit).offset(offset),
63
+ where ? countQuery.where(where) : countQuery,
64
+ ])
65
+
66
+ return { items: data, total: countResult[0]?.count ?? 0, limit, offset }
67
+ {{/if}}
16
68
  })
17
69
  {{/if}}
18
70
  {{#if (includes operations "create")}}
19
71
  .post('/', async ({ body }) => {
20
- 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()
83
+ {{#if hasRealtime}}
84
+ publish{{pascalCase entity}}('created', created)
85
+ {{/if}}
21
86
  return created
22
87
  })
23
88
  {{/if}}
24
- .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}}
25
100
  const [item] = await db.select().from({{camelCase entity}}s).where(eq({{camelCase entity}}s.id, Number(id))).limit(1)
26
- if (!item) { set.status = 404; return { error: 'Not found' } }
101
+ {{/if}}
102
+ if (!item) throw new VaspError('NOT_FOUND', '{{pascalCase entity}} not found', 404)
27
103
  return item
28
104
  })
29
105
  {{#if (includes operations "update")}}
30
- .put('/:id', async ({ params: { id }, body, set }) => {
31
- const [updated] = await db.update({{camelCase entity}}s).set(body).where(eq({{camelCase entity}}s.id, Number(id))).returning()
32
- 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)
119
+ {{#if hasRealtime}}
120
+ publish{{pascalCase entity}}('updated', updated)
121
+ {{/if}}
33
122
  return updated
34
123
  })
35
124
  {{/if}}
36
125
  {{#if (includes operations "delete")}}
37
- .delete('/:id', async ({ params: { id }, set }) => {
126
+ .delete('/:id', async ({ params: { id } }) => {
38
127
  const [deleted] = await db.delete({{camelCase entity}}s).where(eq({{camelCase entity}}s.id, Number(id))).returning()
39
- if (!deleted) { set.status = 404; return { error: 'Not found' } }
128
+ if (!deleted) throw new VaspError('NOT_FOUND', '{{pascalCase entity}} not found', 404)
129
+ {{#if hasRealtime}}
130
+ publish{{pascalCase entity}}('deleted', deleted)
131
+ {{/if}}
40
132
  return { ok: true }
41
133
  })
42
134
  {{/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
@@ -1,30 +1,78 @@
1
1
  import { Elysia } from 'elysia'
2
+ {{#if ../hasAuth}}
3
+ import jwt from '@elysiajs/jwt'
4
+ {{/if}}
2
5
 
3
- // In-process subscriber set for '{{name}}' channel
4
- // Swap this for a Redis adapter in production multi-instance deployments
5
- const subscribers = new Set()
6
+ // Room-based subscriber map for '{{name}}' channel
7
+ // Each room key maps to a set of connected WebSocket clients
8
+ const rooms = new Map()
9
+
10
+ function getRoom(roomId) {
11
+ if (!rooms.has(roomId)) rooms.set(roomId, new Set())
12
+ return rooms.get(roomId)
13
+ }
6
14
 
7
15
  /**
8
- * Publish a realtime event to all connected subscribers of '{{name}}'.
16
+ * Publish a realtime event to all subscribers in a specific room of '{{name}}'.
9
17
  * Called automatically by CRUD mutation handlers.
10
18
  */
11
- export function publish{{pascalCase name}}(event, data) {
19
+ export function publish{{pascalCase name}}(event, data, roomId = 'default') {
20
+ const room = rooms.get(roomId)
21
+ if (!room) return
22
+ const message = JSON.stringify({ channel: '{{camelCase name}}', room: roomId, event, data })
23
+ for (const ws of room) {
24
+ try { ws.send(message) } catch { room.delete(ws) }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Broadcast to all rooms of '{{name}}'.
30
+ */
31
+ export function broadcast{{pascalCase name}}(event, data) {
12
32
  const message = JSON.stringify({ channel: '{{camelCase name}}', event, data })
13
- for (const ws of subscribers) {
14
- try { ws.send(message) } catch { subscribers.delete(ws) }
33
+ for (const [, room] of rooms) {
34
+ for (const ws of room) {
35
+ try { ws.send(message) } catch { room.delete(ws) }
36
+ }
15
37
  }
16
38
  }
17
39
 
18
40
  export const {{camelCase name}}Channel = new Elysia()
41
+ {{#if ../hasAuth}}
42
+ .use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET || 'vasp-dev-secret' }))
43
+ {{/if}}
19
44
  .ws('/ws/{{camelCase name}}', {
45
+ {{#if ../hasAuth}}
46
+ async beforeHandle({ jwt: jwtPlugin, request }) {
47
+ const url = new URL(request.url)
48
+ const token = url.searchParams.get('token')
49
+ if (!token) return new Response('Unauthorized', { status: 401 })
50
+ const payload = await jwtPlugin.verify(token)
51
+ if (!payload) return new Response('Unauthorized', { status: 401 })
52
+ },
53
+ {{/if}}
20
54
  open(ws) {
21
- subscribers.add(ws)
55
+ const room = ws.data?.query?.room ?? 'default'
56
+ getRoom(room).add(ws)
57
+ ws.data._room = room
22
58
  },
23
59
  message(ws, msg) {
24
- // Echo back for debugging; extend for room-based subscriptions
60
+ // Client can switch rooms via { action: 'join', room: 'roomId' }
61
+ try {
62
+ const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg
63
+ if (parsed.action === 'join' && parsed.room) {
64
+ const oldRoom = ws.data._room
65
+ if (oldRoom) rooms.get(oldRoom)?.delete(ws)
66
+ ws.data._room = parsed.room
67
+ getRoom(parsed.room).add(ws)
68
+ ws.send(JSON.stringify({ ack: 'joined', room: parsed.room }))
69
+ return
70
+ }
71
+ } catch { /* ignore parse errors */ }
25
72
  ws.send(JSON.stringify({ ack: msg }))
26
73
  },
27
74
  close(ws) {
28
- subscribers.delete(ws)
75
+ const room = ws.data?._room
76
+ if (room) rooms.get(room)?.delete(ws)
29
77
  },
30
78
  })
@@ -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
+ })