@michaelthielemann/kestrel 1.2.1 → 1.3.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 (40) hide show
  1. package/README.md +33 -3
  2. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +200 -0
  3. package/layers/auth/nuxt.config.ts +0 -0
  4. package/layers/auth/server/api/auth/session.get.ts +0 -0
  5. package/layers/auth/server/utils/password.ts +0 -0
  6. package/layers/auth/server/utils/session.ts +5 -2
  7. package/layers/collections/nuxt.config.ts +0 -0
  8. package/layers/core/modules/kestrel/app-shell.ts +55 -0
  9. package/layers/core/modules/kestrel/index.ts +17 -0
  10. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -0
  11. package/layers/core/server/api/[collection]/options.get.test.ts +60 -0
  12. package/layers/core/server/api/[collection]/translations.get.test.ts +90 -0
  13. package/layers/core/server/utils/blocks.ts +0 -0
  14. package/layers/core/server/utils/seo.ts +0 -0
  15. package/layers/fields/nuxt.config.ts +0 -0
  16. package/layers/media/server/api/media/[id].get.ts +0 -0
  17. package/layers/media/server/api/media/index.get.ts +0 -0
  18. package/layers/public/app/app.vue +0 -0
  19. package/layers/public/app/error.vue +0 -0
  20. package/layers/public/app/layouts/default.vue +0 -0
  21. package/layers/ui/app/assets/scss/_reset.scss +0 -0
  22. package/layers/ui/app/assets/scss/main.scss +0 -0
  23. package/layers/ui/app/components/ui/Alert.vue +0 -0
  24. package/package.json +6 -2
  25. package/scripts/copy-create-payload.mjs +52 -0
  26. package/scripts/hash-password.mjs +6 -15
  27. package/scripts/kestrel.mjs +216 -0
  28. package/scripts/lib/cli.mjs +114 -0
  29. package/scripts/lib/password.mjs +19 -0
  30. package/scripts/lib/scaffold.mjs +174 -0
  31. package/templates/starter/README.md +65 -0
  32. package/templates/starter/_env.example +17 -0
  33. package/templates/starter/_gitignore +26 -0
  34. package/templates/starter/_package.json +22 -0
  35. package/templates/starter/app/app.vue +7 -0
  36. package/templates/starter/app/blocks/Prose.vue +12 -0
  37. package/templates/starter/app/layouts/default.vue +6 -0
  38. package/templates/starter/nuxt.config.ts +20 -0
  39. package/templates/starter/pnpm-workspace.yaml +8 -0
  40. package/templates/starter/tsconfig.json +3 -0
package/README.md CHANGED
@@ -35,6 +35,9 @@ static host. It is deliberately **not**:
35
35
 
36
36
  ## Features
37
37
 
38
+ - **Runnable in one command** — `pnpm create kestrel` scaffolds a project that boots with a working
39
+ `/admin`, prompting for the admin password and writing its hash; `kestrel init` does the same to an
40
+ existing project without clobbering it, and `kestrel doctor` names whatever is still missing.
38
41
  - **Collection-driven** — declare collections + fields in TypeScript; Kestrel derives the SQLite tables, a
39
42
  typed CRUD REST API, and the full admin UI. The schema **migrates itself** (additive in dev; explicit
40
43
  `db:migrate` in prod).
@@ -58,10 +61,26 @@ static host. It is deliberately **not**:
58
61
 
59
62
  ## Quickstart (consumer)
60
63
 
64
+ ```bash
65
+ pnpm create kestrel my-site
66
+ cd my-site && pnpm install && pnpm dev
67
+ ```
68
+
69
+ It asks for an admin password and writes a project that runs as-is: `nuxt.config.ts` extending the
70
+ meta-layer, an `app.vue` that renders, a `.env` holding a fresh session secret and the scrypt hash of
71
+ your password, and one example block. Sign in at <http://localhost:3000/admin>.
72
+
73
+ Already have a project? Run it in place — existing files are kept, `package.json` and `.env` are merged:
74
+
61
75
  ```bash
62
76
  pnpm add @michaelthielemann/kestrel
77
+ pnpm kestrel init # completes the project
78
+ pnpm kestrel doctor # or just diagnose one that misbehaves
63
79
  ```
64
80
 
81
+ Installing the package **alone does nothing**: Nuxt only loads Kestrel once a config extends it. If you
82
+ would rather wire it up by hand, that is two files:
83
+
65
84
  ```ts
66
85
  // nuxt.config.ts
67
86
  export default defineNuxtConfig({
@@ -88,8 +107,13 @@ export default defineCollection({
88
107
 
89
108
  Set the auth env (`KESTREL_SESSION_SECRET`, `KESTREL_ADMIN_PASSWORD_HASH`), start the app, and manage
90
109
  content at `/admin`. You bring your own **public layout** and **block SFCs**
91
- (`app/blocks/Hero.vue` — one file for schema + display — is the `hero` block). Full guide:
92
- **[consuming-kestrel.md](docs/consuming-kestrel.md)**.
110
+ (`app/blocks/Hero.vue` — one file for schema + display — is the `hero` block).
111
+
112
+ > **Do not add an `app/app.vue` that omits `<NuxtPage />`.** A project-owned one shadows the layer's, and
113
+ > the file `nuxi init` writes renders `<NuxtWelcome />` instead of your routes — the admin then appears to
114
+ > be missing rather than blank. Kestrel reports this at build time; `kestrel doctor` catches it earlier.
115
+
116
+ Full guide: **[consuming-kestrel.md](docs/consuming-kestrel.md)**.
93
117
 
94
118
  ## Documentation
95
119
 
@@ -125,7 +149,10 @@ The CMS is split into Nuxt layers under `layers/`:
125
149
  - **`admin`** — the editor SPA: collection list, record editor, the 3-pane block editor.
126
150
  - **`public`** — the SSG render path: the catch-all page, `BlockRenderer`, sitemap / robots / llms.txt, deploy.
127
151
 
128
- `playground/` is a small consuming example.
152
+ `playground/` is a small consuming example. `templates/starter/` is what the scaffolder writes out;
153
+ `scripts/kestrel.mjs` is the engine's CLI and `packages/create-kestrel/` the standalone
154
+ `pnpm create kestrel` front end, which copies the same templates in at pack time rather than keeping
155
+ its own.
129
156
 
130
157
  ## Development
131
158
 
@@ -144,6 +171,9 @@ pnpm test:e2e # end-to-end tests (real dev server)
144
171
  pnpm db:generate # drizzle-kit: generate a migration
145
172
  pnpm db:migrate # drizzle-kit: apply migrations
146
173
  pnpm hash-password # produce a KESTREL_ADMIN_PASSWORD_HASH
174
+
175
+ node scripts/kestrel.mjs init <dir> # the consumer scaffolder, from a checkout
176
+ node scripts/kestrel.mjs doctor <dir> # diagnose a consumer project
147
177
  ```
148
178
 
149
179
  In dev, the schema auto-syncs from the collection definitions (additive changes only); production applies
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useState } from '#imports'
3
+ import { flushPromises } from '@vue/test-utils'
4
+ import { readBody } from 'h3'
5
+ import { mountSuspended, registerEndpoint, mockNuxtImport } from '@nuxt/test-utils/runtime'
6
+ import RecordPage from './[id].vue'
7
+
8
+ const thingsSchema = {
9
+ name: 'things', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
10
+ blocks: { enabled: false }, label: { singular: 'Thing', plural: 'Things' },
11
+ fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
12
+ }
13
+ // Carries an explicit `label.new` — the create heading must use it verbatim, not the generic template.
14
+ const articlesSchema = {
15
+ name: 'articles', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
16
+ blocks: { enabled: false }, label: { singular: 'Article', plural: 'Articles', new: 'Compose article' },
17
+ fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
18
+ }
19
+ registerEndpoint('/api/collections', () => ({ data: [thingsSchema, articlesSchema] }))
20
+ registerEndpoint('/api/articles', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
21
+
22
+ let patched: Record<string, unknown> | null = null
23
+ let posted: Record<string, unknown> | null = null
24
+ registerEndpoint('/api/things/1', async (event) => {
25
+ if (event.method === 'PATCH') { patched = await readBody(event); return { id: 1, ...patched } }
26
+ return { id: 1, title: 'Existing' }
27
+ })
28
+ // A record whose title field is blank — the heading must fall back to the generic "#id" phrase.
29
+ registerEndpoint('/api/things/2', () => ({ id: 2, title: ' ' }))
30
+ registerEndpoint('/api/things', async (event) => {
31
+ if (event.method === 'POST') { posted = await readBody(event); return { id: 7, ...posted } }
32
+ return { data: [], total: 0, page: 1, perPage: 25 }
33
+ })
34
+
35
+ // The editor's Delete flows through the shared batch endpoint + the referrer-aggregate preview.
36
+ let bulkBody: Record<string, unknown> | null = null
37
+ registerEndpoint('/api/things/bulk', async (event) => {
38
+ bulkBody = await readBody(event)
39
+ return { action: bulkBody!.action, count: 1, ids: bulkBody!.ids }
40
+ })
41
+ registerEndpoint('/api/references/referrers', () => ({ counts: {} }))
42
+
43
+ // Route + navigation are mocked so the page reads collection/id from `h.params` and navigations are captured.
44
+ const h = vi.hoisted(() => ({ params: { collection: 'things', id: '1' }, nav: [] as unknown[] }))
45
+ mockNuxtImport('useRoute', () => () => ({ params: h.params, query: {}, fullPath: `/admin/${h.params.collection}/${h.params.id}` }))
46
+ mockNuxtImport('navigateTo', () => (to: unknown) => { h.nav.push(to); return Promise.resolve() })
47
+
48
+ beforeEach(() => {
49
+ h.params = { collection: 'things', id: '1' }
50
+ h.nav.length = 0
51
+ patched = null
52
+ posted = null
53
+ bulkBody = null
54
+ useState('kestrel-collections').value = null
55
+ useState('kestrel-blocks').value = null
56
+ })
57
+
58
+ const settle = async () => {
59
+ await new Promise((r) => setTimeout(r, 20))
60
+ await flushPromises()
61
+ }
62
+
63
+ describe('record editor page header', () => {
64
+ it('merges Save, Cancel and Delete into the record head, each with an icon', async () => {
65
+ const w = await mountSuspended(RecordPage)
66
+ await flushPromises()
67
+
68
+ const head = w.find('.record__head')
69
+ expect(head.exists()).toBe(true)
70
+ const buttons = head.findAll('.ui-button')
71
+ expect(buttons.map((b) => b.text())).toEqual(expect.arrayContaining(['Save', 'Cancel', 'Delete']))
72
+ for (const b of buttons) expect(b.find('.ui-icon').exists()).toBe(true)
73
+ expect(w.find('.editor__actions').exists()).toBe(false)
74
+
75
+ // Cancel carries real button chrome — it sits beside the solid Delete/Save, not as bare text…
76
+ expect(buttons.find((b) => b.text() === 'Cancel')!.classes()).toContain('ui-button--secondary')
77
+ // …while the icon-only tools (undo/redo/open) stay quiet ghosts.
78
+ const iconOnly = buttons.filter((b) => b.text() === '')
79
+ expect(iconOnly.length).toBeGreaterThan(0)
80
+ for (const b of iconOnly) expect(b.classes()).toContain('ui-button--ghost')
81
+ })
82
+
83
+ it('titles the header with the singular label, not the raw (plural) route param', async () => {
84
+ h.params = { collection: 'things', id: 'new' }
85
+ const wNew = await mountSuspended(RecordPage)
86
+ await flushPromises()
87
+ expect(wNew.find('.record__title').text()).toBe('New Thing')
88
+ expect(wNew.find('.record__back').text()).toContain('Things')
89
+ // The generic phrase is title-cased; a real record title would not be.
90
+ expect(wNew.find('.record__title').classes()).toContain('record__title--generic')
91
+ })
92
+
93
+ it('keeps the back link on the heading row (one line, so the panes keep the vertical space)', async () => {
94
+ const w = await mountSuspended(RecordPage)
95
+ await flushPromises()
96
+ expect(w.find('.record__head .record__back').exists()).toBe(true)
97
+ })
98
+
99
+ it('titles a saved record with its own title — an id means nothing to an editor', async () => {
100
+ h.params = { collection: 'things', id: '1' }
101
+ const wEdit = await mountSuspended(RecordPage)
102
+ await settle()
103
+ expect(wEdit.find('.record__title').text()).toBe('Existing')
104
+ expect(wEdit.find('.record__title').classes()).not.toContain('record__title--generic')
105
+ })
106
+
107
+ it('falls back to "Edit {collection} #{id}" when the record has no usable title', async () => {
108
+ h.params = { collection: 'things', id: '2' }
109
+ const wEdit = await mountSuspended(RecordPage)
110
+ await settle()
111
+ expect(wEdit.find('.record__title').text()).toBe('Edit Thing #2')
112
+ expect(wEdit.find('.record__title').classes()).toContain('record__title--generic')
113
+ })
114
+
115
+ it('uses the collection\'s explicit label.new for the create heading (no generic "New X")', async () => {
116
+ h.params = { collection: 'articles', id: 'new' }
117
+ const w = await mountSuspended(RecordPage)
118
+ await flushPromises()
119
+ expect(w.find('.record__title').text()).toBe('Compose article')
120
+ })
121
+
122
+ it('wires the header Save button to submit the editor form', async () => {
123
+ const w = await mountSuspended(RecordPage)
124
+ await flushPromises()
125
+ const save = w.findAll('.record__head .ui-button').find((b) => b.text() === 'Save')!
126
+ expect(save.attributes('type')).toBe('submit')
127
+ expect(save.attributes('form')).toBe('record-editor')
128
+ expect(w.find('form.editor').attributes('id')).toBe('record-editor')
129
+ })
130
+
131
+ it('shows the back link with a centered SVG arrow (no text arrow glyph)', async () => {
132
+ const w = await mountSuspended(RecordPage)
133
+ await flushPromises()
134
+ const back = w.find('.record__back')
135
+ expect(back.exists()).toBe(true)
136
+ expect(back.find('.ui-icon').exists()).toBe(true)
137
+ expect(back.text()).not.toContain('←')
138
+ })
139
+
140
+ it('saving an existing record stays on the page — Save only saves', async () => {
141
+ const w = await mountSuspended(RecordPage)
142
+ await flushPromises()
143
+ await w.find('form.editor').trigger('submit')
144
+ await settle()
145
+ expect(patched).toMatchObject({ title: 'Existing' })
146
+ expect(h.nav).toEqual([])
147
+ })
148
+
149
+ it('saving a NEW record moves to its own editor, not back to the list', async () => {
150
+ h.params = { collection: 'things', id: 'new' }
151
+ const w = await mountSuspended(RecordPage)
152
+ await flushPromises()
153
+ await w.findAll('input')[0]!.setValue('Fresh')
154
+ await w.find('form.editor').trigger('submit')
155
+ await settle()
156
+ expect(posted).toMatchObject({ title: 'Fresh' })
157
+ // navigates to the created record's editor (id 7), never to the list path
158
+ expect(h.nav).toContain('/admin/things/7')
159
+ expect(h.nav).not.toContain('/admin/things')
160
+ })
161
+
162
+ it('deletes through the confirm dialog (shared batch op) and navigates back to the list', async () => {
163
+ const w = await mountSuspended(RecordPage)
164
+ await settle()
165
+ // the header Delete opens the dialog rather than window.confirm
166
+ const del = w.findAll('.record__head .ui-button').find((b) => b.text() === 'Delete')!
167
+ await del.trigger('click')
168
+ await settle()
169
+ expect(w.find('.ui-dialog__content').exists()).toBe(true)
170
+ // confirming posts the bulk delete for this one id and then navigates to the list
171
+ const confirm = w.findAll('.ui-dialog__content .ui-button').find((b) => /^delete$/i.test(b.text().trim()))!
172
+ await confirm.trigger('click')
173
+ await settle()
174
+ expect(bulkBody).toEqual({ action: 'delete', ids: [1] })
175
+ expect(h.nav).toContain('/admin/things')
176
+ })
177
+
178
+ it('a native beforeunload (tab close / reload) is blocked only while there are unsaved changes', async () => {
179
+ const w = await mountSuspended(RecordPage)
180
+ await flushPromises()
181
+
182
+ // pristine → unload is NOT blocked
183
+ const clean = new Event('beforeunload', { cancelable: true })
184
+ window.dispatchEvent(clean)
185
+ expect(clean.defaultPrevented).toBe(false)
186
+
187
+ // edit a field → dirty → unload IS blocked (browser shows its native prompt)
188
+ await w.findAll('input')[0]!.setValue('Changed')
189
+ await settle()
190
+ const dirty = new Event('beforeunload', { cancelable: true })
191
+ window.dispatchEvent(dirty)
192
+ expect(dirty.defaultPrevented).toBe(true)
193
+
194
+ // unmounting removes the listener (no leak across pages)
195
+ w.unmount()
196
+ const afterUnmount = new Event('beforeunload', { cancelable: true })
197
+ window.dispatchEvent(afterUnmount)
198
+ expect(afterUnmount.defaultPrevented).toBe(false)
199
+ })
200
+ })
File without changes
File without changes
File without changes
@@ -52,7 +52,7 @@ export interface SessionSettings {
52
52
  cookieName: string
53
53
  }
54
54
 
55
- export function sessionSettings(): SessionSettings {
55
+ export function sessionSettings({ prerender = import.meta.prerender === true } = {}): SessionSettings {
56
56
  // Treat anything that isn't an EXPLICIT dev signal as production for these safeguards, so a deployment
57
57
  // that simply omits NODE_ENV (a common slip when launching `.output/server/index.mjs`) is hardened —
58
58
  // not silently downgraded to dev, which would tolerate a missing secret + non-Secure cookies. Vitest
@@ -60,7 +60,10 @@ export function sessionSettings(): SessionSettings {
60
60
  const explicitDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || process.env.KESTREL_DEV === '1'
61
61
  const isProd = !explicitDev
62
62
  const secureCookies = process.env.KESTREL_SECURE_COOKIES !== 'false'
63
- if (isProd && !secureCookies) {
63
+ // `nuxt generate` prerenders every page through `/api/route`, which passes the access guard and lands
64
+ // here with NODE_ENV=production. A prerender request never issues a cookie, so enforcing the flag there
65
+ // only means a dev `.env` silently drops pages from the static output.
66
+ if (isProd && !secureCookies && !prerender) {
64
67
  throw new Error('KESTREL_SECURE_COOKIES=false is not allowed in production')
65
68
  }
66
69
  const rawMaxAge = Number(process.env.KESTREL_SESSION_MAX_AGE)
File without changes
@@ -0,0 +1,55 @@
1
+ export type AppShellDiagnostic = { level: 'error' | 'warn'; message: string }
2
+
3
+ export type AppShellInput = {
4
+ mainComponent: string | null | undefined
5
+ pagesEnabled: boolean
6
+ read: (file: string) => string
7
+ }
8
+
9
+ const withoutComments = (src: string) => src.replace(/<!--[\s\S]*?-->/g, '')
10
+
11
+ // `<nuxt-page />` is as valid as `<NuxtPage />`; matching only the Pascal spelling would flag a working app.
12
+ const kebab = (tag: string) => tag.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()
13
+ const uses = (src: string, tag: string) => new RegExp(`<\\s*(${tag}|${kebab(tag)})[\\s/>]`, 'i').test(src)
14
+
15
+ const FIX = `
16
+ <template>
17
+ <NuxtLayout>
18
+ <NuxtPage />
19
+ </NuxtLayout>
20
+ </template>`
21
+
22
+ /** Reports only — assigning `mainComponent` would defeat a legitimate override. See ADR-0005. */
23
+ export function diagnoseAppShell({ mainComponent, pagesEnabled, read }: AppShellInput): AppShellDiagnostic[] {
24
+ const found: AppShellDiagnostic[] = []
25
+
26
+ if (!pagesEnabled) {
27
+ found.push({
28
+ level: 'error',
29
+ message:
30
+ 'the pages feature is disabled (`pages: false`), so no route is registered at all — the admin is a set of pages under `app/pages/admin/`. Remove the override to reach /admin.',
31
+ })
32
+ }
33
+
34
+ if (mainComponent) {
35
+ let src: string | undefined
36
+ try {
37
+ src = withoutComments(read(mainComponent))
38
+ } catch {
39
+ // An unreadable app root is Nuxt's to report; guessing here would produce a phantom error.
40
+ }
41
+ if (src !== undefined && !uses(src, 'NuxtPage')) {
42
+ found.push({
43
+ level: 'error',
44
+ message: `${mainComponent} renders no <NuxtPage />, so NO route renders — including /admin. It shadows Kestrel's own app.vue because the consumer layer wins. Either delete it (Kestrel ships a working one) or make it:${FIX}`,
45
+ })
46
+ } else if (src !== undefined && !uses(src, 'NuxtLayout')) {
47
+ found.push({
48
+ level: 'warn',
49
+ message: `${mainComponent} renders no <NuxtLayout />, so page-level \`layout\` is ignored and the admin loses its navigation shell. Wrap <NuxtPage /> in <NuxtLayout>.`,
50
+ })
51
+ }
52
+ }
53
+
54
+ return found
55
+ }
@@ -1,5 +1,7 @@
1
+ import { readFileSync } from 'node:fs'
1
2
  import { defineNuxtModule } from '@nuxt/kit'
2
3
  import { resolveKestrel, type KestrelConfig } from '../../server/utils/kestrel-config'
4
+ import { diagnoseAppShell } from './app-shell'
3
5
 
4
6
  /**
5
7
  * The `kestrel` config namespace (`kestrel: { … }` in nuxt.config, sourced from `kestrel.config.ts`).
@@ -13,6 +15,21 @@ export default defineNuxtModule<KestrelConfig>({
13
15
  setup(options, nuxt) {
14
16
  const c = resolveKestrel(options, process.env, nuxt.options.rootDir)
15
17
 
18
+ // `app:resolve` is the first hook where `mainComponent` is settled across all layers. In dev it fires
19
+ // on every watched change, so an unfixed problem would repaint the whole message on each keystroke.
20
+ const reported = new Set<string>()
21
+ nuxt.hook('app:resolve', (app) => {
22
+ for (const d of diagnoseAppShell({
23
+ mainComponent: app.mainComponent,
24
+ pagesEnabled: nuxt.options.pages !== false,
25
+ read: (file) => readFileSync(file, 'utf8'),
26
+ })) {
27
+ if (reported.has(d.message)) continue
28
+ reported.add(d.message)
29
+ console[d.level === 'error' ? 'error' : 'warn'](`[kestrel] ${d.message}`)
30
+ }
31
+ })
32
+
16
33
  const rc = nuxt.options.runtimeConfig
17
34
  rc.media = {
18
35
  driver: c.media.driver,
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { createError } from 'h3'
3
+ import Database from 'better-sqlite3'
4
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
5
+ import { buildCollection } from '../../../../fields/server/utils/buildCollection'
6
+ import { defineCollection } from '../../utils/defineCollection'
7
+ import { create } from '../../utils/crud'
8
+ import { requireCollection, parseIdList } from '../../utils/http'
9
+ import { clearRegistry, registerCollection } from '../../utils/registry'
10
+ import { pickerOptions } from '../../utils/picker'
11
+ import { desiredSchema } from '../../schema/desired'
12
+ import { diffSchema } from '../../schema/diff'
13
+ import { renderSqlite } from '../../schema/render-sqlite'
14
+
15
+ const posts = buildCollection(defineCollection({
16
+ name: 'posts', mode: 'multi', translatable: false,
17
+ fields: { title: { type: 'text', required: true } },
18
+ }))
19
+
20
+ interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string>; readScope?: string } }
21
+
22
+ let db: ReturnType<typeof drizzle>
23
+
24
+ // Same rationale as translations.get.test.ts: bind the handler's auto-imported helpers to the REAL
25
+ // implementations so this proves the actual wiring, not a stub the server does not have.
26
+ Object.assign(globalThis, {
27
+ defineEventHandler: (handler: unknown) => handler,
28
+ createError,
29
+ getQuery: (event: FakeEvent) => event.query,
30
+ useDb: () => db,
31
+ requireCollection,
32
+ parseIdList,
33
+ pickerOptions,
34
+ publishedOnlyForScope: () => false,
35
+ })
36
+
37
+ const handler = (await import('./options.get')).default as unknown as (event: FakeEvent) => ReturnType<typeof pickerOptions>
38
+ const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
39
+
40
+ beforeEach(() => {
41
+ const sqlite = new Database(':memory:')
42
+ for (const stmt of renderSqlite(diffSchema(desiredSchema([posts.table]), {}))) sqlite.exec(stmt)
43
+ db = drizzle(sqlite)
44
+ clearRegistry()
45
+ registerCollection(posts)
46
+ })
47
+ afterEach(() => clearRegistry())
48
+
49
+ describe('GET /api/{collection}/options?ids=', () => {
50
+ it('resolves more than 100 ids in one request instead of truncating', () => {
51
+ const ids = Array.from({ length: 150 }, (_, i) => (create(db, posts, { title: `T${i}` }) as { id: number }).id)
52
+ const r = get('posts', { ids: ids.join(',') })
53
+ expect(r.data.length).toBe(150)
54
+ })
55
+
56
+ it('400s (never silently truncates) an ids list over the shared bulk cap', () => {
57
+ const ids = Array.from({ length: 501 }, (_, i) => i + 1)
58
+ expect(() => get('posts', { ids: ids.join(',') })).toThrowError(expect.objectContaining({ statusCode: 400 }))
59
+ })
60
+ })
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { createError } from 'h3'
3
+ import Database from 'better-sqlite3'
4
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
5
+ import { buildCollection } from '../../../../fields/server/utils/buildCollection'
6
+ import { defineCollection } from '../../utils/defineCollection'
7
+ import { create, resolveTranslations } from '../../utils/crud'
8
+ import { requireCollection } from '../../utils/http'
9
+ import { clearRegistry, registerCollection } from '../../utils/registry'
10
+ import { desiredSchema } from '../../schema/desired'
11
+ import { diffSchema } from '../../schema/diff'
12
+ import { renderSqlite } from '../../schema/render-sqlite'
13
+
14
+ const pages = buildCollection(defineCollection({
15
+ name: 'pages', mode: 'multi', translatable: true, pageLike: true,
16
+ fields: { title: { type: 'text', required: true } },
17
+ }))
18
+ const notes = buildCollection(defineCollection({
19
+ name: 'notes', mode: 'multi', translatable: false,
20
+ fields: { title: { type: 'text', required: true } },
21
+ }))
22
+
23
+ // `context.params` is where Nitro puts the route params the real `requireCollection` reads via h3.
24
+ interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string> } }
25
+
26
+ let db: ReturnType<typeof drizzle>
27
+
28
+ // The handler is a Nitro route: its auto-imported helpers are plain globals in a node test — bound to the
29
+ // REAL implementations so nothing here can pass against a stub the server does not have.
30
+ Object.assign(globalThis, {
31
+ defineEventHandler: (handler: unknown) => handler,
32
+ createError,
33
+ getQuery: (event: FakeEvent) => event.query,
34
+ useDb: () => db,
35
+ requireCollection,
36
+ resolveTranslations,
37
+ })
38
+
39
+ // Calling the handler directly says nothing about the path ever reaching it: that the literal `translations`
40
+ // segment wins over the sibling `[id]` route is only provable on a real server → `test/e2e/api.test.ts`.
41
+ const handler = (await import('./translations.get')).default as unknown as (event: FakeEvent) => Record<string, number | null>
42
+ const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
43
+
44
+ beforeEach(() => {
45
+ const sqlite = new Database(':memory:')
46
+ for (const stmt of renderSqlite(diffSchema(desiredSchema([pages.table, notes.table]), {}))) sqlite.exec(stmt)
47
+ db = drizzle(sqlite)
48
+ clearRegistry()
49
+ registerCollection(pages)
50
+ registerCollection(notes)
51
+ })
52
+ afterEach(() => clearRegistry())
53
+
54
+ describe('GET /api/{collection}/translations?group=', () => {
55
+ it('resolves the group\'s locale → sibling id map without a record id', () => {
56
+ const en = create(db, pages, { title: 'Home' }) as Record<string, unknown>
57
+ const de = create(db, pages, { title: 'Start', locale: 'de', translationGroup: en.translationGroup as string }) as Record<string, unknown>
58
+ expect(get('pages', { group: en.translationGroup })).toEqual({ en: en.id, de: de.id })
59
+ })
60
+
61
+ it('reports a locale with no sibling as null (what the "+ create" affordance keys off)', () => {
62
+ const en = create(db, pages, { title: 'Only EN' }) as Record<string, unknown>
63
+ expect(get('pages', { group: en.translationGroup })).toEqual({ en: en.id, de: null })
64
+ })
65
+
66
+ it('returns exactly the per-record map for the same group (one map builder, two entry points)', () => {
67
+ const en = create(db, pages, { title: 'Home' }) as Record<string, unknown>
68
+ create(db, pages, { title: 'Start', locale: 'de', translationGroup: en.translationGroup as string })
69
+ expect(get('pages', { group: en.translationGroup })).toEqual(resolveTranslations(db, pages, en.id as number))
70
+ })
71
+
72
+ it('400s without a group rather than answering for an arbitrary one', () => {
73
+ expect(() => get('pages', {})).toThrowError(expect.objectContaining({ statusCode: 400 }))
74
+ expect(() => get('pages', { group: ' ' })).toThrowError(expect.objectContaining({ statusCode: 400 }))
75
+ })
76
+
77
+ it('404s for a group that has no rows', () => {
78
+ create(db, pages, { title: 'Home' })
79
+ expect(() => get('pages', { group: 'nope' })).toThrowError(expect.objectContaining({ statusCode: 404 }))
80
+ })
81
+
82
+ it('400s (never 500s) for a collection without translations — it has no group column at all', () => {
83
+ create(db, notes, { title: 'Note' })
84
+ expect(() => get('notes', { group: 'g1' })).toThrowError(/Translations are not enabled/)
85
+ })
86
+
87
+ it('404s for an unknown collection', () => {
88
+ expect(() => get('nope', { group: 'g1' })).toThrowError(expect.objectContaining({ statusCode: 404 }))
89
+ })
90
+ })
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michaelthielemann/kestrel",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "A slim, collection-driven Nuxt 4 CMS meta-layer with a runtime schema engine. Add `extends: ['@michaelthielemann/kestrel']`, define collections, and the database migrates itself.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Michael Thielemann <283621694+MichaelThielemann@users.noreply.github.com>",
@@ -21,18 +21,22 @@
21
21
  ],
22
22
  "type": "module",
23
23
  "main": "./nuxt.config.ts",
24
+ "bin": {
25
+ "kestrel": "./scripts/kestrel.mjs"
26
+ },
24
27
  "packageManager": "pnpm@11.9.0",
25
28
  "publishConfig": {
26
29
  "access": "public",
27
30
  "provenance": true
28
31
  },
29
- "//publish": "Consumed as a meta-layer: `extends: ['@michaelthielemann/kestrel']`. `main` points at the layer's nuxt.config so the BARE specifier resolves — without it c12 can't resolve the package, silently drops the whole layer (\"Cannot extend config from …\"), and every auto-import/component vanishes. Use `main`, NOT `exports` (exports would gate subpaths like `@michaelthielemann/kestrel/scripts/hash-password.mjs` + the deep config-type import). `files` ships the entry + sub-layers + operator scripts; the `!**/*.test.ts` globs strip tests. Releases run from the tag workflow via npm trusted publishing (OIDC), so no npm token exists anywhere.",
32
+ "//publish": "Consumed as a meta-layer: `extends: ['@michaelthielemann/kestrel']`. `main` points at the layer's nuxt.config so the BARE specifier resolves — without it c12 can't resolve the package, silently drops the whole layer (\"Cannot extend config from …\"), and every auto-import/component vanishes. Use `main`, NOT `exports` (exports would gate subpaths like `@michaelthielemann/kestrel/scripts/hash-password.mjs` + the deep config-type import); `bin` resolves by path from the package root, so it coexists with `main` untouched. `files` ships the entry + sub-layers + operator scripts + the `kestrel init` templates; the `!**/*.test.ts` globs strip tests — they are GLOBAL, so nothing under `templates/` may be named `*.test.ts` or it vanishes from the tarball. Template dotfiles are `_`-prefixed for the same class of reason: npm strips a literal `.gitignore` from a tarball and then applies it, taking its listed siblings with it. Releases run from the tag workflow via npm trusted publishing (OIDC), so no npm token exists anywhere.",
30
33
  "files": [
31
34
  "NOTICE",
32
35
  "nuxt.config.ts",
33
36
  "kestrel.config.ts",
34
37
  "layers",
35
38
  "scripts",
39
+ "templates",
36
40
  "!**/*.test.ts",
37
41
  "!**/*.dom.test.ts",
38
42
  "!**/*.nuxt.test.ts"