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.
- package/dist/vasp +80 -31
- package/package.json +2 -2
- package/starters/minimal.vasp +1 -1
- package/starters/recipe.vasp +70 -0
- package/starters/todo-auth-ssr.vasp +33 -20
- package/starters/todo.vasp +15 -8
- package/templates/shared/.gitignore.hbs +1 -0
- package/templates/shared/README.md.hbs +53 -0
- package/templates/shared/auth/client/Login.vue.hbs +1 -1
- package/templates/shared/auth/client/Register.vue.hbs +1 -1
- package/templates/shared/auth/server/index.hbs +4 -8
- package/templates/shared/auth/server/middleware.hbs +33 -15
- package/templates/shared/auth/server/plugin.hbs +7 -0
- 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 +39 -9
- package/templates/shared/jobs/_job.hbs +12 -2
- package/templates/shared/package.json.hbs +14 -4
- package/templates/shared/server/db/client.hbs +19 -1
- package/templates/shared/server/db/seed.hbs +16 -0
- package/templates/shared/server/index.hbs +48 -0
- package/templates/shared/server/middleware/errorHandler.hbs +75 -0
- package/templates/shared/server/middleware/logger.hbs +74 -0
- package/templates/{templates/shared → 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 +103 -11
- package/templates/shared/server/routes/queries/_query.hbs +5 -1
- package/templates/shared/server/routes/realtime/_channel.hbs +58 -10
- 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/js/plugins/vasp.client.js.hbs +11 -1
- package/templates/ssr/ts/error.vue.hbs +26 -0
- package/templates/ssr/ts/nuxt.config.ts.hbs +1 -0
- package/templates/ssr/ts/plugins/vasp.client.ts.hbs +11 -1
- 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/.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 -51
- 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 -69
- package/templates/templates/shared/bunfig.toml.hbs +0 -2
- 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 -35
- package/templates/templates/shared/server/db/client.hbs +0 -12
- package/templates/templates/shared/server/index.hbs +0 -60
- 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/.env.example.hbs → shared/.env.hbs} +0 -0
- /package/templates/{templates/shared → shared}/drizzle/drizzle.config.hbs +0 -0
- /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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 }
|
|
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
|
-
|
|
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
|
|
31
|
-
const
|
|
32
|
-
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)
|
|
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 }
|
|
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)
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
const
|
|
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
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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}}
|