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.
- package/README.md +57 -2
- package/dist/vasp +201 -35
- package/package.json +2 -2
- package/starters/minimal.vasp +1 -1
- package/starters/recipe.vasp +11 -20
- package/starters/todo-auth-ssr.vasp +33 -20
- package/starters/todo.vasp +15 -8
- package/templates/shared/.gitignore.hbs +1 -0
- package/templates/shared/auth/server/index.hbs +4 -8
- package/templates/shared/auth/server/middleware.hbs +33 -15
- package/templates/{templates/shared → shared}/auth/server/plugin.hbs +0 -2
- package/templates/shared/auth/server/providers/github.hbs +1 -1
- package/templates/shared/auth/server/providers/google.hbs +1 -1
- package/templates/shared/auth/server/providers/usernameAndPassword.hbs +3 -6
- package/templates/shared/bunfig.toml.hbs +3 -0
- package/templates/shared/drizzle/schema.hbs +38 -19
- package/templates/shared/package.json.hbs +11 -3
- package/templates/shared/server/db/client.hbs +19 -1
- package/templates/shared/server/db/seed.hbs +16 -0
- package/templates/shared/server/index.hbs +47 -0
- package/templates/shared/server/middleware/errorHandler.hbs +75 -0
- package/templates/shared/server/middleware/logger.hbs +74 -0
- package/templates/shared/server/middleware/rateLimit.hbs +2 -2
- package/templates/shared/server/routes/_vasp.hbs +37 -0
- package/templates/shared/server/routes/actions/_action.hbs +5 -1
- package/templates/shared/server/routes/api/_api.hbs +24 -0
- package/templates/shared/server/routes/crud/_crud.hbs +58 -10
- package/templates/shared/server/routes/queries/_query.hbs +5 -1
- package/templates/shared/shared/types.hbs +58 -0
- package/templates/shared/shared/validation.hbs +20 -0
- package/templates/shared/tests/actions/_action.test.js.hbs +7 -0
- package/templates/shared/tests/actions/_action.test.ts.hbs +7 -0
- package/templates/shared/tests/auth/login.test.js.hbs +7 -0
- package/templates/shared/tests/auth/login.test.ts.hbs +7 -0
- package/templates/shared/tests/crud/_entity.test.js.hbs +7 -0
- package/templates/shared/tests/crud/_entity.test.ts.hbs +7 -0
- package/templates/shared/tests/queries/_query.test.js.hbs +7 -0
- package/templates/shared/tests/queries/_query.test.ts.hbs +7 -0
- package/templates/shared/tests/setup.js.hbs +5 -0
- package/templates/shared/tests/setup.ts.hbs +5 -0
- package/templates/shared/tests/vitest.config.js.hbs +8 -0
- package/templates/shared/tests/vitest.config.ts.hbs +8 -0
- package/templates/shared/tsconfig.json.hbs +2 -1
- package/templates/spa/js/src/App.vue.hbs +9 -1
- package/templates/spa/js/src/components/VaspErrorBoundary.vue.hbs +33 -0
- package/templates/spa/js/src/components/VaspNotifications.vue.hbs +60 -0
- package/templates/spa/js/src/vasp/auth.js.hbs +31 -15
- package/templates/spa/js/src/vasp/client/actions.js.hbs +7 -1
- package/templates/spa/js/src/vasp/client/crud.js.hbs +94 -5
- package/templates/spa/js/src/vasp/useVaspNotifications.js.hbs +35 -0
- package/templates/spa/js/vite.config.js.hbs +1 -0
- package/templates/spa/ts/src/App.vue.hbs +9 -1
- package/templates/spa/ts/src/components/VaspErrorBoundary.vue.hbs +33 -0
- package/templates/spa/ts/src/components/VaspNotifications.vue.hbs +60 -0
- package/templates/spa/ts/src/vasp/auth.ts.hbs +31 -15
- package/templates/spa/ts/src/vasp/client/actions.ts.hbs +7 -1
- package/templates/spa/ts/src/vasp/client/crud.ts.hbs +96 -10
- package/templates/spa/ts/src/vasp/client/types.ts.hbs +14 -28
- package/templates/spa/ts/src/vasp/useVaspNotifications.ts.hbs +41 -0
- package/templates/spa/ts/vite.config.ts.hbs +1 -0
- package/templates/ssr/js/error.vue.hbs +23 -0
- package/templates/ssr/js/nuxt.config.js.hbs +1 -0
- package/templates/ssr/ts/error.vue.hbs +26 -0
- package/templates/ssr/ts/nuxt.config.ts.hbs +1 -0
- package/templates/starters/minimal.vasp +15 -0
- package/templates/starters/recipe.vasp +70 -0
- package/templates/starters/todo-auth-ssr.vasp +65 -0
- package/templates/starters/todo.vasp +42 -0
- package/templates/templates/shared/.env.example.hbs +0 -14
- package/templates/templates/shared/.gitignore.hbs +0 -8
- package/templates/templates/shared/auth/client/Login.vue.hbs +0 -46
- package/templates/templates/shared/auth/client/Register.vue.hbs +0 -42
- package/templates/templates/shared/auth/server/index.hbs +0 -46
- package/templates/templates/shared/auth/server/middleware.hbs +0 -33
- package/templates/templates/shared/auth/server/providers/github.hbs +0 -48
- package/templates/templates/shared/auth/server/providers/google.hbs +0 -53
- package/templates/templates/shared/auth/server/providers/usernameAndPassword.hbs +0 -66
- package/templates/templates/shared/bunfig.toml.hbs +0 -5
- package/templates/templates/shared/drizzle/drizzle.config.hbs +0 -10
- package/templates/templates/shared/drizzle/schema.hbs +0 -48
- package/templates/templates/shared/jobs/_job.hbs +0 -34
- package/templates/templates/shared/jobs/boss.hbs +0 -15
- package/templates/templates/shared/package.json.hbs +0 -38
- package/templates/templates/shared/server/db/client.hbs +0 -12
- package/templates/templates/shared/server/index.hbs +0 -73
- package/templates/templates/shared/server/middleware/csrf.hbs +0 -34
- package/templates/templates/shared/server/middleware/rateLimit.hbs +0 -44
- package/templates/templates/shared/server/routes/actions/_action.hbs +0 -20
- package/templates/templates/shared/server/routes/crud/_crud.hbs +0 -86
- package/templates/templates/shared/server/routes/jobs/_schedule.hbs +0 -12
- package/templates/templates/shared/server/routes/queries/_query.hbs +0 -20
- package/templates/templates/shared/server/routes/realtime/_channel.hbs +0 -78
- package/templates/templates/shared/server/routes/realtime/index.hbs +0 -9
- package/templates/templates/shared/tsconfig.json.hbs +0 -21
- package/templates/templates/spa/js/index.html.hbs +0 -12
- package/templates/templates/spa/js/src/App.vue.hbs +0 -3
- package/templates/templates/spa/js/src/main.js.hbs +0 -9
- package/templates/templates/spa/js/src/router/index.js.hbs +0 -41
- package/templates/templates/spa/js/src/vasp/auth.js.hbs +0 -45
- package/templates/templates/spa/js/src/vasp/client/actions.js.hbs +0 -15
- package/templates/templates/spa/js/src/vasp/client/crud.js.hbs +0 -30
- package/templates/templates/spa/js/src/vasp/client/index.js.hbs +0 -16
- package/templates/templates/spa/js/src/vasp/client/queries.js.hbs +0 -15
- package/templates/templates/spa/js/src/vasp/client/realtime.js.hbs +0 -51
- package/templates/templates/spa/js/src/vasp/plugin.js.hbs +0 -11
- package/templates/templates/spa/js/vite.config.js.hbs +0 -26
- package/templates/templates/spa/ts/index.html.hbs +0 -12
- package/templates/templates/spa/ts/src/App.vue.hbs +0 -3
- package/templates/templates/spa/ts/src/main.ts.hbs +0 -9
- package/templates/templates/spa/ts/src/router/index.ts.hbs +0 -41
- package/templates/templates/spa/ts/src/vasp/auth.ts.hbs +0 -53
- package/templates/templates/spa/ts/src/vasp/client/actions.ts.hbs +0 -19
- package/templates/templates/spa/ts/src/vasp/client/crud.ts.hbs +0 -37
- package/templates/templates/spa/ts/src/vasp/client/index.ts.hbs +0 -17
- package/templates/templates/spa/ts/src/vasp/client/queries.ts.hbs +0 -19
- package/templates/templates/spa/ts/src/vasp/client/realtime.ts.hbs +0 -56
- package/templates/templates/spa/ts/src/vasp/client/types.ts.hbs +0 -33
- package/templates/templates/spa/ts/src/vasp/plugin.ts.hbs +0 -12
- package/templates/templates/spa/ts/vite.config.ts.hbs +0 -26
- package/templates/templates/ssr/js/_page.vue.hbs +0 -10
- package/templates/templates/ssr/js/app.vue.hbs +0 -3
- package/templates/templates/ssr/js/composables/useAuth.js.hbs +0 -52
- package/templates/templates/ssr/js/composables/useVasp.js.hbs +0 -6
- package/templates/templates/ssr/js/middleware/auth.js.hbs +0 -8
- package/templates/templates/ssr/js/nuxt.config.js.hbs +0 -15
- package/templates/templates/ssr/js/plugins/vasp.client.js.hbs +0 -27
- package/templates/templates/ssr/js/plugins/vasp.server.js.hbs +0 -33
- package/templates/templates/ssr/ts/_page.vue.hbs +0 -10
- package/templates/templates/ssr/ts/app.vue.hbs +0 -3
- package/templates/templates/ssr/ts/composables/useAuth.ts.hbs +0 -56
- package/templates/templates/ssr/ts/composables/useVasp.ts.hbs +0 -10
- package/templates/templates/ssr/ts/middleware/auth.ts.hbs +0 -8
- package/templates/templates/ssr/ts/nuxt.config.ts.hbs +0 -19
- package/templates/templates/ssr/ts/plugins/vasp.client.ts.hbs +0 -27
- package/templates/templates/ssr/ts/plugins/vasp.server.ts.hbs +0 -33
- /package/templates/{templates/shared → shared}/.env.hbs +0 -0
- /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
|
-
|
|
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 {
|
|
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
|
|
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 }
|
|
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
|
-
|
|
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
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
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 }
|
|
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)
|
|
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}}
|
|
@@ -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
|
-
<
|
|
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>
|