@escalay/kit 0.0.0-stage → 1.7.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/cli.mjs +432 -0
  4. package/docs/STANDARD.md +342 -0
  5. package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
  6. package/docs/decisions/0002-node-and-workers.md +45 -0
  7. package/package.json +58 -3
  8. package/registry.json +512 -0
  9. package/src/a2a/README.md +25 -0
  10. package/src/a2a/index.ts +262 -0
  11. package/src/boards/README.md +35 -0
  12. package/src/boards/handlers.ts +87 -0
  13. package/src/boards/index.ts +64 -0
  14. package/src/boards/refs.ts +23 -0
  15. package/src/boards/repo.ts +50 -0
  16. package/src/boards/routes.ts +83 -0
  17. package/src/boards/schema.ts +28 -0
  18. package/src/boards/tools.ts +76 -0
  19. package/src/browser/README.md +31 -0
  20. package/src/browser/boards-client.ts +74 -0
  21. package/src/browser/boards-react.ts +73 -0
  22. package/src/browser/search-client.ts +258 -0
  23. package/src/browser/search-react.ts +116 -0
  24. package/src/browser/vector-search-block.tsx +94 -0
  25. package/src/core/README.md +37 -0
  26. package/src/core/api.ts +239 -0
  27. package/src/core/auth.ts +147 -0
  28. package/src/core/cache.ts +53 -0
  29. package/src/core/discovery.ts +315 -0
  30. package/src/core/guards.ts +109 -0
  31. package/src/core/hal.ts +63 -0
  32. package/src/core/kv.ts +29 -0
  33. package/src/core/problem.ts +124 -0
  34. package/src/db/README.md +39 -0
  35. package/src/db/guards.ts +61 -0
  36. package/src/db/index.ts +122 -0
  37. package/src/guide/README.md +38 -0
  38. package/src/guide/guide.ts +152 -0
  39. package/src/guide/reference.ts +204 -0
  40. package/src/index.ts +15 -0
  41. package/src/mcp/README.md +34 -0
  42. package/src/mcp/index.ts +251 -0
  43. package/src/oauth/README.md +29 -0
  44. package/src/oauth/index.ts +439 -0
  45. package/src/oauth/schema.ts +7 -0
  46. package/src/oauth/store.ts +34 -0
  47. package/src/pagination/README.md +31 -0
  48. package/src/pagination/index.ts +65 -0
  49. package/src/testing/README.md +31 -0
  50. package/src/testing/index.ts +162 -0
  51. package/src/vector-search/README.md +27 -0
  52. package/src/vector-search/index.ts +367 -0
  53. package/src/vectors/README.md +30 -0
  54. package/src/vectors/index.ts +326 -0
  55. package/src/web/README.md +21 -0
  56. package/src/web/index.ts +100 -0
  57. package/templates/app/AGENTS.md +41 -0
  58. package/templates/app/CLAUDE.md +3 -0
  59. package/templates/app/Dockerfile +12 -0
  60. package/templates/app/container/entrypoint.sh +7 -0
  61. package/templates/app/gitignore +4 -0
  62. package/templates/app/guide/llms.txt +11 -0
  63. package/templates/app/pnpm-workspace.yaml +5 -0
  64. package/templates/app/scripts/guide.ts +8 -0
  65. package/templates/app/src/app.ts +39 -0
  66. package/templates/app/src/features/index.ts +30 -0
  67. package/templates/app/src/server.ts +14 -0
  68. package/templates/app/test/api.test.ts +34 -0
  69. package/templates/app/tsconfig.json +16 -0
  70. package/templates/feature/feature.test.ts +19 -0
  71. package/templates/feature/handlers.ts +43 -0
  72. package/templates/feature/index.ts +34 -0
  73. package/templates/feature/repo.ts +24 -0
  74. package/templates/feature/routes.ts +43 -0
  75. package/templates/feature/schema.ts +14 -0
  76. package/templates/feature/tools.ts +46 -0
  77. package/templates/runtime-cloudflare/dev.vars.example +3 -0
  78. package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
  79. package/templates/runtime-cloudflare/src/worker.ts +19 -0
  80. package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
  81. package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
  82. package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
  83. package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
  84. package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
  85. package/templates/runtime-cloudflare/vitest.config.ts +23 -0
  86. package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { createApp, TOOLS } from '../src/app.js'
5
+ import { FEATURES } from '../src/features/index.js'
6
+ import { guideFiles, guideModuleSource, hashApiKey, testDb } from '../src/kit/index.js'
7
+ import { guideGaps, lintOpenApi, toolRoutes, toolsRun } from '../src/kit/testing/index.js'
8
+
9
+ const GUIDE_DIR = fileURLToPath(new URL('../guide', import.meta.url))
10
+ const KEY = 'test-key'
11
+ const headers = { authorization: `Bearer ${KEY}` }
12
+ const app = await createApp({ db: await testDb(), env: { API_KEYS: `tester:${hashApiKey(KEY)}` } })
13
+ const request = async (path: string, init: RequestInit = {}) => app.request(path, { ...init, headers: { ...headers, 'content-type': 'application/json', ...(init.headers as Record<string, string>) } })
14
+ const doc = async () => (await app.request('/openapi.json', { headers })).json()
15
+
16
+ describe('the API', () => {
17
+ it('documents every route to the standard, and every tool lands on one', async () => {
18
+ expect(lintOpenApi(await doc())).toEqual([])
19
+ expect(toolRoutes(TOOLS, await doc())).toEqual([])
20
+ })
21
+
22
+ it('runs every MCP tool against its route', async () => {
23
+ const samples = Object.assign({}, ...(await Promise.all(FEATURES.map((feature) => feature.samples(request)))))
24
+ expect(await toolsRun(TOOLS, app, samples, { headers })).toEqual([])
25
+ })
26
+
27
+ it('fills every placeholder in the agent guide', async () => {
28
+ expect(guideGaps(guideFiles(GUIDE_DIR), {}, { doc: await doc(), tools: TOOLS })).toEqual([])
29
+ })
30
+
31
+ it('serves the guide folder as it is now (run `pnpm guide` after editing guide/)', () => {
32
+ expect(readFileSync(new URL('../src/guide.gen.ts', import.meta.url), 'utf8')).toBe(guideModuleSource(guideFiles(GUIDE_DIR)))
33
+ })
34
+ })
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["node"],
11
+ "noUncheckedIndexedAccess": true
12
+ },
13
+ "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"],
14
+ "$comment": "Workers code is checked by test/workers/tsconfig.json when the app runs on Cloudflare",
15
+ "exclude": ["src/worker.ts", "test/workers"]
16
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp } from '../../app.js'
3
+ import { hashApiKey, testDb } from '../../kit/index.js'
4
+
5
+ const headers = { authorization: 'Bearer test-key', 'content-type': 'application/json' }
6
+ const app = await createApp({ db: await testDb(), env: { API_KEYS: `tester:${hashApiKey('test-key')}` } })
7
+
8
+ describe('__labels__', () => {
9
+ it('makes, lists, changes and deletes a __label__', async () => {
10
+ const made = await app.request('/api/__name__', { method: 'POST', headers, body: JSON.stringify({ title: 'First' }) })
11
+ expect(made.status).toBe(201)
12
+ const { id } = (await made.json()) as { id: string }
13
+ expect(made.headers.get('location')).toBe(`/api/__name__/${id}`)
14
+ expect(await (await app.request('/api/__name__', { headers })).json()).toMatchObject({ count: 1 })
15
+ expect(await (await app.request(`/api/__name__/${id}`, { method: 'PATCH', headers, body: JSON.stringify({ body: 'more' }) })).json()).toMatchObject({ title: 'First', body: 'more' })
16
+ expect((await app.request(`/api/__name__/${id}`, { method: 'DELETE', headers })).status).toBe(204)
17
+ expect((await app.request(`/api/__name__/${id}`, { headers })).status).toBe(404)
18
+ })
19
+ })
@@ -0,0 +1,43 @@
1
+ /** The __labels__ routes' handlers: request → repo → HAL answer; a missing __label__ is a problem. No queries here. */
2
+ import type { RouteHandler } from '@hono/zod-openapi'
3
+ import { linksOf, originOf, ProblemError } from '../../kit/index.js'
4
+ import type { __Pascal__Repo } from './repo.js'
5
+ import { PATH, type routes } from './routes.js'
6
+ import type { __Singular__Row } from './schema.js'
7
+
8
+ type Routes = typeof routes
9
+
10
+ export const __camel__Handlers = (repo: __Pascal__Repo) => {
11
+ const rowOf = async (id: string) => {
12
+ const row = await repo.get(id)
13
+ if (!row) throw new ProblemError('not_found', { detail: `No __label__ ${id}. GET ${PATH} lists them.` })
14
+ return row
15
+ }
16
+ const cardOf = (origin: string, row: __Singular__Row) => ({ ...row, _links: linksOf(origin, { self: `${PATH}/${row.id}`, list: PATH }) })
17
+
18
+ const list: RouteHandler<Routes['list']> = async (c) => {
19
+ const rows = await repo.list(c.req.valid('query').limit)
20
+ const origin = originOf(c)
21
+ return c.json({ count: rows.length, _links: linksOf(origin, { self: PATH }), _embedded: { __camel__: rows.map((row) => cardOf(origin, row)) } }, 200)
22
+ }
23
+ const get: RouteHandler<Routes['get']> = async (c) => c.json(cardOf(originOf(c), await rowOf(c.req.valid('param').id)), 200)
24
+ const create: RouteHandler<Routes['create']> = async (c) => {
25
+ const { title, body } = c.req.valid('json')
26
+ const id = await repo.create({ title: title.trim(), body: body ?? null })
27
+ return c.json(cardOf(originOf(c), await rowOf(id)), 201, { location: `${PATH}/${id}` })
28
+ }
29
+ const update: RouteHandler<Routes['update']> = async (c) => {
30
+ const { id } = c.req.valid('param')
31
+ await rowOf(id)
32
+ const { title, body } = c.req.valid('json')
33
+ await repo.update(id, { ...(title === undefined ? {} : { title: title.trim() }), ...(body === undefined ? {} : { body }) })
34
+ return c.json(cardOf(originOf(c), await rowOf(id)), 200)
35
+ }
36
+ const remove: RouteHandler<Routes['remove']> = async (c) => {
37
+ const { id } = c.req.valid('param')
38
+ await rowOf(id)
39
+ await repo.remove(id)
40
+ return c.body(null, 204)
41
+ }
42
+ return { list, get, create, update, remove }
43
+ }
@@ -0,0 +1,34 @@
1
+ /** __Pascal__: the feature as the app registers it (routes and tables), its MCP tools, and sample tool arguments. */
2
+ import { migrate } from '../../kit/index.js'
3
+ import type { Feature } from '../index.js'
4
+ import { __camel__Handlers } from './handlers.js'
5
+ import { __camel__Repo } from './repo.js'
6
+ import { PATH, routes, TAG } from './routes.js'
7
+ import { __CONST___MIGRATIONS } from './schema.js'
8
+ import { __camel__Tools } from './tools.js'
9
+
10
+ export const feature: Feature = {
11
+ name: '__name__',
12
+ tag: { name: TAG, description: '__Pascal__: make, list, change and delete them.' },
13
+ migrations: __CONST___MIGRATIONS,
14
+ register: async (app, { db }) => {
15
+ await migrate(db, '__name__', __CONST___MIGRATIONS)
16
+ const handle = __camel__Handlers(__camel__Repo(db))
17
+ app.openapi(routes.list, handle.list)
18
+ app.openapi(routes.get, handle.get)
19
+ app.openapi(routes.create, handle.create)
20
+ app.openapi(routes.update, handle.update)
21
+ app.openapi(routes.remove, handle.remove)
22
+ },
23
+ tools: __camel__Tools,
24
+ samples: async (request) => {
25
+ const { id } = (await (await request(PATH, { method: 'POST', body: JSON.stringify({ title: 'A sample __label__' }) })).json()) as { id: string }
26
+ return {
27
+ list___snake__: {},
28
+ get___snake_singular__: { id },
29
+ create___snake_singular__: { title: 'Made by a tool' },
30
+ update___snake_singular__: { id, body: 'Changed by a tool' },
31
+ delete___snake_singular__: { id },
32
+ }
33
+ },
34
+ }
@@ -0,0 +1,24 @@
1
+ /** __Pascal__ in the database: the only place they are queried, and nothing about HTTP. */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { desc, eq } from 'drizzle-orm'
4
+ import type { Db } from '../../kit/index.js'
5
+ import { __camel__ } from './schema.js'
6
+
7
+ export const __camel__Repo = (db: Db) => {
8
+ const now = () => new Date().toISOString()
9
+ return {
10
+ /** The newest changed first. */
11
+ list: (limit: number) => db.select().from(__camel__).orderBy(desc(__camel__.updatedAt)).limit(limit),
12
+ get: async (id: string) => (await db.select().from(__camel__).where(eq(__camel__.id, id)))[0],
13
+ create: async ({ title, body }: { title: string; body?: string | null }) => {
14
+ const id = randomUUID()
15
+ const at = now()
16
+ await db.insert(__camel__).values({ id, title, body: body ?? null, createdAt: at, updatedAt: at })
17
+ return id
18
+ },
19
+ update: (id: string, change: { title?: string; body?: string | null }) => db.update(__camel__).set({ ...change, updatedAt: now() }).where(eq(__camel__.id, id)),
20
+ remove: (id: string) => db.delete(__camel__).where(eq(__camel__.id, id)),
21
+ }
22
+ }
23
+
24
+ export type __Pascal__Repo = ReturnType<typeof __camel__Repo>
@@ -0,0 +1,43 @@
1
+ /** The __labels__ routes as OpenAPI contracts: paths, parameters, bodies and answers. */
2
+ import { createRoute, z } from '@hono/zod-openapi'
3
+ import { HAL_JSON, LinksSchema, problemResponses } from '../../kit/index.js'
4
+
5
+ export const PATH = '/api/__name__'
6
+ export const TAG = '__Pascal__'
7
+
8
+ export const __Singular__Schema = z
9
+ .object({ id: z.string(), title: z.string(), body: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(), _links: LinksSchema })
10
+ .openapi('__Singular__', { description: 'A __label__.' })
11
+ const __Singular__ListSchema = z.object({ count: z.number().int(), _links: LinksSchema, _embedded: z.object({ __camel__: z.array(__Singular__Schema) }) }).openapi('__Singular__List', { description: '__Pascal__, the newest changed first.' })
12
+ const __Singular__InputSchema = z.object({ title: z.string().min(1).max(200).openapi({ example: 'First __label__' }), body: z.string().max(10_000).optional().openapi({ description: 'Its text.' }) }).openapi('__Singular__Input', { description: 'A __label__’s title and text.' })
13
+ const __Singular__PatchSchema = __Singular__InputSchema.partial().openapi('__Singular__Patch', { description: 'A new title or text.' })
14
+ const id = z.string().min(1).max(80).openapi({ description: 'The __label__’s id.' })
15
+ const ok = <Schema extends z.ZodTypeAny>(description: string, schema: Schema) => ({ content: { [HAL_JSON]: { schema } }, description })
16
+
17
+ export const routes = {
18
+ list: createRoute({
19
+ method: 'get', path: PATH, operationId: 'list__Pascal__', tags: [TAG], summary: '__Pascal__', description: 'Every __label__, the newest changed first, each with its link.',
20
+ request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(50).openapi({ description: 'At most this many (1–100, default 50).' }) }) },
21
+ responses: { 200: ok('__Pascal__.', __Singular__ListSchema), ...problemResponses(400, 401) },
22
+ }),
23
+ get: createRoute({
24
+ method: 'get', path: `${PATH}/{id}`, operationId: 'get__Singular__', tags: [TAG], summary: 'One __label__', description: 'A __label__ with its title and text.',
25
+ request: { params: z.object({ id }) },
26
+ responses: { 200: ok('The __label__.', __Singular__Schema), ...problemResponses(401, 404) },
27
+ }),
28
+ create: createRoute({
29
+ method: 'post', path: PATH, operationId: 'create__Singular__', tags: [TAG], summary: 'Make a __label__', description: 'A new __label__. Answers 201 with its address in `Location`.',
30
+ request: { body: { required: true, content: { 'application/json': { schema: __Singular__InputSchema } } } },
31
+ responses: { 201: ok('The new __label__.', __Singular__Schema), ...problemResponses(400, 401, 403) },
32
+ }),
33
+ update: createRoute({
34
+ method: 'patch', path: `${PATH}/{id}`, operationId: 'update__Singular__', tags: [TAG], summary: 'Change a __label__', description: 'Changes a __label__’s title, its text, or both.',
35
+ request: { params: z.object({ id }), body: { required: true, content: { 'application/json': { schema: __Singular__PatchSchema } } } },
36
+ responses: { 200: ok('The __label__.', __Singular__Schema), ...problemResponses(400, 401, 403, 404) },
37
+ }),
38
+ remove: createRoute({
39
+ method: 'delete', path: `${PATH}/{id}`, operationId: 'delete__Singular__', tags: [TAG], summary: 'Delete a __label__', description: 'Deletes the __label__ for good.',
40
+ request: { params: z.object({ id }) },
41
+ responses: { 204: { description: 'Deleted.' }, ...problemResponses(401, 403, 404) },
42
+ }),
43
+ }
@@ -0,0 +1,14 @@
1
+ /** The __labels__ table, and the SQL that makes it: one migration step per change, never edited once it has run. */
2
+ import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
3
+
4
+ export const __camel__ = sqliteTable('__table__', {
5
+ id: text('id').primaryKey(),
6
+ title: text('title').notNull(),
7
+ body: text('body'),
8
+ createdAt: text('created_at').notNull(),
9
+ updatedAt: text('updated_at').notNull(),
10
+ })
11
+
12
+ export const __CONST___MIGRATIONS = ['create table if not exists __table__ (id text primary key, title text not null, body text, created_at text not null, updated_at text not null)'] as const
13
+
14
+ export type __Singular__Row = typeof __camel__.$inferSelect
@@ -0,0 +1,46 @@
1
+ /** The __labels__ routes as MCP tools: thin calls an agent makes with the owner's credentials. */
2
+ import { z } from '@hono/zod-openapi'
3
+ import { mcpTool, type McpTool } from '../../kit/mcp/index.js'
4
+ import { PATH } from './routes.js'
5
+
6
+ type Card = { id: string; title: string; body: string | null; updatedAt: string }
7
+ const line = (row: Card) => `${row.title} · id ${row.id} · changed ${row.updatedAt.slice(0, 10)}`
8
+ const id = z.string().describe('The __label__’s id, from list___snake__.')
9
+
10
+ export const __camel__Tools: McpTool[] = [
11
+ mcpTool({
12
+ name: 'list___snake__', title: '__Pascal__', description: 'Every __label__, the newest changed first, with its id. Start here.',
13
+ input: z.object({ limit: z.number().int().min(1).max(100).optional().describe('At most this many (default 50).') }),
14
+ annotations: { readOnlyHint: true, openWorldHint: false },
15
+ call: (args) => ({ path: PATH, query: args }),
16
+ summarize: (json: { _embedded: { __camel__: Card[] } }) => json._embedded.__camel__.map(line).join('\n') || 'None yet.',
17
+ }),
18
+ mcpTool({
19
+ name: 'get___snake_singular__', title: 'One __label__', description: 'A __label__ with its full text.',
20
+ input: z.object({ id }),
21
+ annotations: { readOnlyHint: true, openWorldHint: false },
22
+ call: ({ id }) => ({ path: `${PATH}/${encodeURIComponent(id)}` }),
23
+ summarize: (row: Card) => `${line(row)}\n\n${row.body ?? '(no text)'}`,
24
+ }),
25
+ mcpTool({
26
+ name: 'create___snake_singular__', title: 'Make a __label__', description: 'Makes a __label__ with a title and, optionally, its text.',
27
+ input: z.object({ title: z.string().min(1).max(200), body: z.string().max(10_000).optional() }),
28
+ annotations: { openWorldHint: false },
29
+ call: (body) => ({ method: 'POST', path: PATH, body }),
30
+ summarize: (row: Card) => `Made: ${line(row)}`,
31
+ }),
32
+ mcpTool({
33
+ name: 'update___snake_singular__', title: 'Change a __label__', description: 'Changes a __label__’s title, its text, or both.',
34
+ input: z.object({ id, title: z.string().min(1).max(200).optional(), body: z.string().max(10_000).optional() }),
35
+ annotations: { idempotentHint: true, openWorldHint: false },
36
+ call: ({ id, ...change }) => ({ method: 'PATCH', path: `${PATH}/${encodeURIComponent(id)}`, body: change }),
37
+ summarize: (row: Card) => `Changed: ${line(row)}`,
38
+ }),
39
+ mcpTool({
40
+ name: 'delete___snake_singular__', title: 'Delete a __label__', description: 'Deletes a __label__ for good. Ask the owner first.',
41
+ input: z.object({ id }),
42
+ annotations: { destructiveHint: true, openWorldHint: false },
43
+ call: ({ id }) => ({ method: 'DELETE', path: `${PATH}/${encodeURIComponent(id)}` }),
44
+ summarize: () => 'Deleted.',
45
+ }),
46
+ ]
@@ -0,0 +1,3 @@
1
+ # Local secrets for `wrangler dev`: copy to .dev.vars (git-ignored). API_KEYS holds name:sha256 hashes, never keys.
2
+ API_KEYS=""
3
+ PUBLIC_HOST="localhost"
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Writes migrations/ for D1 from the kit's tables and every feature's steps, one file per step with its kit_migrations
3
+ * record, so `wrangler d1 migrations apply DB --remote` brings the database up at deploy and `migrate()` finds every
4
+ * step done at runtime. Files are only added; a step that changed after it was written stops the script (a change is a
5
+ * new step at the end of the feature's list). Run `wrangler d1 migrations apply` before the first `wrangler dev`: a step
6
+ * `migrate()` already ran is replayed, and only `create … if not exists` steps replay cleanly.
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { FEATURES } from '../src/features/index.js'
13
+ import { GUARD_MIGRATIONS, OAUTH_MIGRATIONS } from '../src/kit/index.js'
14
+
15
+ const dir = fileURLToPath(new URL('../migrations', import.meta.url))
16
+ const sets: [string, readonly string[]][] = [['oauth', OAUTH_MIGRATIONS], ['guards', GUARD_MIGRATIONS], ...FEATURES.map((feature): [string, readonly string[]] => [feature.name, feature.migrations])]
17
+ const sha = (text: string) => createHash('sha256').update(text).digest('hex')
18
+ const HEADER = /^-- kit: (\S+) step (\d+) sha256 ([0-9a-f]{64})$/m
19
+
20
+ mkdirSync(dir, { recursive: true })
21
+ const files = readdirSync(dir).filter((file) => file.endsWith('.sql')).sort()
22
+ const written = new Map(files.flatMap((file) => {
23
+ const match = readFileSync(join(dir, file), 'utf8').match(HEADER)
24
+ return match ? [[`${match[1]} ${match[2]}`, { file, hash: match[3] }] as const] : []
25
+ }))
26
+ let next = files.length
27
+ const write = (name: string, text: string) => writeFileSync(join(dir, `${String(next++).padStart(4, '0')}_${name}.sql`), text)
28
+
29
+ if (!files.length) write('kit_migrations', 'create table if not exists kit_migrations (feature text not null, step integer not null, applied_at text not null, primary key (feature, step));\n')
30
+ let added = 0
31
+ for (const [feature, steps] of sets)
32
+ for (const [index, step] of steps.entries()) {
33
+ const shipped = written.get(`${feature} ${index + 1}`)
34
+ if (shipped) {
35
+ if (shipped.hash !== sha(step)) throw new Error(`${feature} step ${index + 1} changed after ${shipped.file} was written; shipped steps never change, add a new step instead`)
36
+ continue
37
+ }
38
+ // `or ignore`: in local dev `migrate()` may have run the step and recorded it first
39
+ const record = `insert or ignore into kit_migrations (feature, step, applied_at) values ('${feature}', ${index + 1}, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));`
40
+ write(`${feature}_${index + 1}`, `-- kit: ${feature} step ${index + 1} sha256 ${sha(step)}\n${step.trim().replace(/;?$/, ';')}\n${record}\n`)
41
+ added++
42
+ }
43
+ console.log(added ? `Wrote ${added} migration files to migrations/` : 'migrations/ is current')
@@ -0,0 +1,19 @@
1
+ /**
2
+ * __title__ on Cloudflare Workers: the D1 binding through Drizzle, then the same `createApp` as on Node. The app is built
3
+ * on the first request of each isolate (Workers allow no I/O or random values at global scope) and kept for the next.
4
+ */
5
+ import { drizzle } from 'drizzle-orm/d1'
6
+ import { createApp, type Env } from './app.js'
7
+ import { openDb, type Db } from './kit/index.js'
8
+
9
+ type Bindings = Env & { DB: D1Database }
10
+
11
+ let ready: ReturnType<typeof createApp> | undefined
12
+
13
+ export default {
14
+ fetch: (request, env, ctx) =>
15
+ (ready ??= openDb(drizzle(env.DB) as unknown as Db).then((db) => createApp({ db, env })).catch((error) => {
16
+ ready = undefined
17
+ throw error
18
+ })).then((app) => app.fetch(request, env, ctx)),
19
+ } satisfies ExportedHandler<Bindings>
@@ -0,0 +1,37 @@
1
+ import { exports } from 'cloudflare:workers'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { TEST_KEY } from './key.js'
4
+
5
+ const headers = { authorization: `Bearer ${TEST_KEY}`, 'content-type': 'application/json' }
6
+
7
+ // worker.ts in workerd over D1, brought up by migrations/: what a deploy runs
8
+ describe('__title__ on Workers', () => {
9
+ it('starts and serves the standard routes and the guide', async () => {
10
+ for (const path of ['/api/health', '/openapi.json', '/llms.txt', '/.well-known/api-catalog']) expect((await exports.default.fetch(`https://api.test${path}`, { headers })).status, path).toBe(200)
11
+ })
12
+
13
+ it('answers every feature list route with a key, and refuses without one', async () => {
14
+ const doc = (await (await exports.default.fetch('https://api.test/openapi.json', { headers })).json()) as { paths: Record<string, { get?: unknown }> }
15
+ const lists = Object.keys(doc.paths).filter((path) => path.startsWith('/api/') && path !== '/api/health' && !path.includes('{') && doc.paths[path]!.get)
16
+ expect(lists.length).toBeGreaterThan(0)
17
+ for (const path of lists) {
18
+ expect((await exports.default.fetch(`https://api.test${path}`, { headers })).status, path).toBe(200)
19
+ expect((await exports.default.fetch(`https://api.test${path}`)).status, path).toBe(401)
20
+ }
21
+ })
22
+
23
+ it('serves MCP: lists the tools and calls one through its route', async () => {
24
+ const meta = { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientCapabilities': {} }
25
+ const rpc = (method: string, params: Record<string, unknown> = {}) =>
26
+ exports.default.fetch('https://api.test/mcp', {
27
+ method: 'POST',
28
+ headers: { ...headers, accept: 'application/json, text/event-stream', 'mcp-protocol-version': '2026-07-28', 'mcp-method': method, ...(method === 'tools/call' ? { 'mcp-name': String(params.name) } : {}) },
29
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: meta } }),
30
+ })
31
+ const list = (await (await rpc('tools/list')).json()) as { result: { tools: { name: string; inputSchema: { required?: string[] } }[] } }
32
+ expect(list.result.tools.length).toBeGreaterThan(0)
33
+ const tool = list.result.tools.find((candidate) => !candidate.inputSchema.required?.length)!
34
+ const called = (await (await rpc('tools/call', { name: tool.name, arguments: {} })).json()) as { result: { isError?: boolean } }
35
+ expect(called.result.isError ?? false, tool.name).toBe(false)
36
+ })
37
+ })
@@ -0,0 +1,5 @@
1
+ import { applyD1Migrations } from 'cloudflare:test'
2
+ import { env } from 'cloudflare:workers'
3
+
4
+ // The same files a deploy applies; applying them twice is a no-op
5
+ await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)
@@ -0,0 +1,11 @@
1
+ declare namespace Cloudflare {
2
+ // Types `exports.default` in the tests as this app's worker
3
+ interface GlobalProps {
4
+ mainModule: typeof import('../../src/worker.js')
5
+ }
6
+ interface Env {
7
+ DB: D1Database
8
+ API_KEYS: string
9
+ TEST_MIGRATIONS: import('cloudflare:test').D1Migration[]
10
+ }
11
+ }
@@ -0,0 +1,2 @@
1
+ /** The API key the workerd tests use; vitest.config.ts puts its hash in API_KEYS. */
2
+ export const TEST_KEY = 'workers-test-key'
@@ -0,0 +1,7 @@
1
+ {
2
+ "$comment": "src/worker.ts and the workerd tests: Workers types, with Node's for what nodejs_compat provides",
3
+ "extends": "../../tsconfig.json",
4
+ "compilerOptions": { "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types", "node"] },
5
+ "include": ["./**/*.ts", "../../src/**/*.ts"],
6
+ "exclude": ["../../src/server.ts", "../../src/**/*.test.ts"]
7
+ }
@@ -0,0 +1,23 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin'
3
+ import { defineConfig } from 'vitest/config'
4
+ import { TEST_KEY } from './test/workers/key.js'
5
+
6
+ // Two projects, one app: `node` runs every test on Node; `workers` runs test/workers inside workerd, over D1 brought
7
+ // up by the files in migrations/ (`pnpm migrations` writes them)
8
+ export default defineConfig(async () => ({
9
+ test: {
10
+ projects: [
11
+ { test: { name: 'node', include: ['src/**/*.test.ts', 'test/**/*.test.ts'], exclude: ['test/workers/**', '**/node_modules/**'] } },
12
+ {
13
+ plugins: [
14
+ cloudflareTest({
15
+ wrangler: { configPath: './wrangler.jsonc' },
16
+ miniflare: { bindings: { API_KEYS: `tester:${createHash('sha256').update(TEST_KEY).digest('hex')}`, TEST_MIGRATIONS: await readD1Migrations('./migrations') } },
17
+ }),
18
+ ],
19
+ test: { name: 'workers', include: ['test/workers/**/*.test.ts'], setupFiles: ['./test/workers/apply-migrations.ts'] },
20
+ },
21
+ ],
22
+ },
23
+ }))
@@ -0,0 +1,16 @@
1
+ // __title__ on Cloudflare Workers. Secrets (API_KEYS, OAUTH_SIGNING_KEY) go in with `wrangler secret put`, locally in
2
+ // .dev.vars (git-ignored; copy .dev.vars.example). Before the first deploy: pin account_id (wrangler's login may be
3
+ // another account), `wrangler d1 create __name__` and put its id below.
4
+ {
5
+ "$schema": "node_modules/wrangler/config-schema.json",
6
+ "name": "__name__",
7
+ "main": "src/worker.ts",
8
+ // "account_id": "<the Cloudflare account this deploys to>",
9
+ "compatibility_date": "__date__",
10
+ "compatibility_flags": ["nodejs_compat"],
11
+ "observability": { "enabled": true },
12
+ "d1_databases": [
13
+ // migrations/ is written by `pnpm migrations` from every feature's steps and applied at deploy
14
+ { "binding": "DB", "database_name": "__name__", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }
15
+ ]
16
+ }