create-top-secret-starter 0.15.0 → 0.16.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/backend-overlay/remove.json +0 -5
- package/backend-overlay/web/src/api/index.ts +4 -0
- package/backend-overlay/web/src/routes/_authenticated/index.tsx +0 -10
- package/backend-overlay/web-start/src/api/index.ts +4 -0
- package/index.js +7 -55
- package/package.json +1 -1
- package/templates/template-api/CLAUDE.md +0 -6
- package/templates/template-api/src/app.test.ts +2 -170
- package/templates/template-api/src/app.ts +0 -24
- package/templates/template-api/src/db/schema.ts +0 -36
- package/templates/template-api/src/index.ts +1 -17
- package/templates/template-router/.claude/settings.json +1 -1
- package/templates/template-router/.claude/skills/project-conventions/SKILL.md +4 -0
- package/templates/template-router/.oxlintrc.json +20 -1
- package/templates/template-router/CLAUDE.md +1 -1
- package/templates/template-router/e2e/notes.spec.ts +6 -0
- package/templates/template-router/package.json +2 -3
- package/templates/template-router/src/api/auth/index.ts +2 -2
- package/templates/template-router/src/api/auth/schema.ts +7 -0
- package/templates/template-router/src/api/index.ts +4 -0
- package/templates/template-router/src/components/app-shell.tsx +22 -18
- package/templates/template-router/src/components/error-screen.tsx +1 -1
- package/templates/template-router/src/components/nav.ts +0 -7
- package/templates/template-router/src/index.css +0 -39
- package/templates/template-router/src/lib/format.ts +2 -37
- package/templates/template-router/src/mocks/server.ts +0 -121
- package/templates/template-router/src/routes/-error.tsx +5 -0
- package/templates/template-router/src/routes/-not-found.tsx +1 -1
- package/templates/template-router/src/routes/_authenticated/index.tsx +1 -16
- package/templates/template-router/src/routes/_authenticated/notes/$noteId.tsx +18 -13
- package/templates/template-router/src/routes/_authenticated/notes/index.tsx +44 -16
- package/templates/template-router/src/routes/sign-in.tsx +3 -7
- package/templates/template-start/.claude/settings.json +1 -1
- package/templates/template-start/.claude/skills/project-conventions/SKILL.md +4 -0
- package/templates/template-start/.oxlintrc.json +20 -1
- package/templates/template-start/CLAUDE.md +1 -1
- package/templates/template-start/e2e/notes.spec.ts +6 -0
- package/templates/template-start/package.json +2 -3
- package/templates/template-start/src/api/auth/index.ts +2 -2
- package/templates/template-start/src/api/auth/schema.ts +7 -0
- package/templates/template-start/src/api/index.ts +4 -0
- package/templates/template-start/src/components/app-shell.tsx +22 -18
- package/templates/template-start/src/components/error-screen.tsx +1 -1
- package/templates/template-start/src/components/nav.ts +0 -7
- package/templates/template-start/src/index.css +0 -39
- package/templates/template-start/src/lib/format.ts +2 -37
- package/templates/template-start/src/mocks/server.ts +0 -121
- package/templates/template-start/src/routes/-error.tsx +5 -0
- package/templates/template-start/src/routes/-not-found.tsx +1 -1
- package/templates/template-start/src/routes/_authenticated/index.tsx +1 -16
- package/templates/template-start/src/routes/_authenticated/notes/$noteId.tsx +18 -13
- package/templates/template-start/src/routes/_authenticated/notes/index.tsx +44 -16
- package/templates/template-start/src/routes/sign-in.tsx +3 -7
- package/backend-overlay/web/src/api/live/index.ts +0 -97
- package/backend-overlay/web/src/api/orders/index.ts +0 -65
- package/backend-overlay/web/src/components/sidebar-user.tsx +0 -33
- package/features.json +0 -51
- package/templates/template-api/src/modules/live/hub.ts +0 -123
- package/templates/template-api/src/modules/live/index.ts +0 -30
- package/templates/template-api/src/modules/live/model.ts +0 -31
- package/templates/template-api/src/modules/orders/index.ts +0 -71
- package/templates/template-api/src/modules/orders/model.ts +0 -49
- package/templates/template-api/src/modules/orders/service.ts +0 -78
- package/templates/template-router/e2e/dashboard.spec.ts +0 -38
- package/templates/template-router/e2e/orders.spec.ts +0 -114
- package/templates/template-router/src/api/live/feed.ts +0 -14
- package/templates/template-router/src/api/live/index.ts +0 -105
- package/templates/template-router/src/api/live/live.test.ts +0 -67
- package/templates/template-router/src/api/live/schema.ts +0 -33
- package/templates/template-router/src/api/orders/index.ts +0 -63
- package/templates/template-router/src/api/orders/input.ts +0 -48
- package/templates/template-router/src/api/orders/orders.test.ts +0 -48
- package/templates/template-router/src/api/orders/schema.ts +0 -20
- package/templates/template-router/src/components/app-shell.plain.tsx +0 -33
- package/templates/template-router/src/components/app-sidebar.tsx +0 -133
- package/templates/template-router/src/components/dashboard/activity-feed.tsx +0 -42
- package/templates/template-router/src/components/dashboard/dashboard.tsx +0 -88
- package/templates/template-router/src/components/dashboard/live-badge.tsx +0 -23
- package/templates/template-router/src/components/dashboard/revenue-chart.tsx +0 -82
- package/templates/template-router/src/components/dashboard/stat-tile.tsx +0 -33
- package/templates/template-router/src/components/orders/create-order-dialog.tsx +0 -143
- package/templates/template-router/src/components/orders/order-status-badge.tsx +0 -28
- package/templates/template-router/src/components/orders/order-status-select.tsx +0 -59
- package/templates/template-router/src/components/orders/order-status.ts +0 -11
- package/templates/template-router/src/components/orders/orders-table-store.ts +0 -50
- package/templates/template-router/src/components/orders/orders-table.tsx +0 -478
- package/templates/template-router/src/components/sidebar-user.tsx +0 -34
- package/templates/template-router/src/components/ui/badge.tsx +0 -48
- package/templates/template-router/src/components/ui/card.tsx +0 -87
- package/templates/template-router/src/components/ui/chart.tsx +0 -338
- package/templates/template-router/src/components/ui/dropdown-menu.tsx +0 -255
- package/templates/template-router/src/components/ui/select.tsx +0 -189
- package/templates/template-router/src/components/ui/sheet.tsx +0 -125
- package/templates/template-router/src/components/ui/sidebar.tsx +0 -682
- package/templates/template-router/src/components/ui/skeleton.tsx +0 -13
- package/templates/template-router/src/components/ui/table.tsx +0 -101
- package/templates/template-router/src/hooks/use-mobile.ts +0 -18
- package/templates/template-router/src/lib/sidebar-store.ts +0 -10
- package/templates/template-router/src/mocks/live.ts +0 -110
- package/templates/template-router/src/mocks/orders.ts +0 -39
- package/templates/template-router/src/mocks/people.ts +0 -38
- package/templates/template-router/src/routes/_authenticated/orders/$orderId.tsx +0 -62
- package/templates/template-router/src/routes/_authenticated/orders/index.tsx +0 -222
- package/templates/template-start/e2e/dashboard.spec.ts +0 -38
- package/templates/template-start/e2e/orders.spec.ts +0 -114
- package/templates/template-start/src/api/live/feed.ts +0 -14
- package/templates/template-start/src/api/live/index.ts +0 -105
- package/templates/template-start/src/api/live/live.test.ts +0 -67
- package/templates/template-start/src/api/live/schema.ts +0 -33
- package/templates/template-start/src/api/orders/index.ts +0 -63
- package/templates/template-start/src/api/orders/input.ts +0 -48
- package/templates/template-start/src/api/orders/orders.test.ts +0 -48
- package/templates/template-start/src/api/orders/schema.ts +0 -20
- package/templates/template-start/src/components/app-shell.plain.tsx +0 -33
- package/templates/template-start/src/components/app-sidebar.tsx +0 -133
- package/templates/template-start/src/components/dashboard/activity-feed.tsx +0 -42
- package/templates/template-start/src/components/dashboard/dashboard.tsx +0 -88
- package/templates/template-start/src/components/dashboard/live-badge.tsx +0 -23
- package/templates/template-start/src/components/dashboard/revenue-chart.tsx +0 -82
- package/templates/template-start/src/components/dashboard/stat-tile.tsx +0 -33
- package/templates/template-start/src/components/orders/create-order-dialog.tsx +0 -143
- package/templates/template-start/src/components/orders/order-status-badge.tsx +0 -28
- package/templates/template-start/src/components/orders/order-status-select.tsx +0 -59
- package/templates/template-start/src/components/orders/order-status.ts +0 -11
- package/templates/template-start/src/components/orders/orders-table-store.ts +0 -50
- package/templates/template-start/src/components/orders/orders-table.tsx +0 -478
- package/templates/template-start/src/components/sidebar-user.tsx +0 -34
- package/templates/template-start/src/components/ui/badge.tsx +0 -48
- package/templates/template-start/src/components/ui/card.tsx +0 -87
- package/templates/template-start/src/components/ui/chart.tsx +0 -338
- package/templates/template-start/src/components/ui/dropdown-menu.tsx +0 -255
- package/templates/template-start/src/components/ui/select.tsx +0 -189
- package/templates/template-start/src/components/ui/sheet.tsx +0 -125
- package/templates/template-start/src/components/ui/sidebar.tsx +0 -682
- package/templates/template-start/src/components/ui/skeleton.tsx +0 -13
- package/templates/template-start/src/components/ui/table.tsx +0 -101
- package/templates/template-start/src/hooks/use-mobile.ts +0 -18
- package/templates/template-start/src/lib/sidebar-store.ts +0 -10
- package/templates/template-start/src/mocks/live.ts +0 -110
- package/templates/template-start/src/mocks/orders.ts +0 -39
- package/templates/template-start/src/mocks/people.ts +0 -38
- package/templates/template-start/src/routes/_authenticated/orders/$orderId.tsx +0 -62
- package/templates/template-start/src/routes/_authenticated/orders/index.tsx +0 -222
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
[
|
|
2
2
|
"src/mocks",
|
|
3
3
|
"src/api/notes/schema.ts",
|
|
4
|
-
"src/api/orders/schema.ts",
|
|
5
|
-
"src/api/orders/orders.test.ts",
|
|
6
|
-
"src/api/live/schema.ts",
|
|
7
|
-
"src/api/live/feed.ts",
|
|
8
|
-
"src/api/live/live.test.ts",
|
|
9
4
|
"src/api/notes/notes.test.ts",
|
|
10
5
|
"src/routes/_authenticated/notes",
|
|
11
6
|
"src/api/auth/schema.ts",
|
|
@@ -17,6 +17,10 @@ export const api = treaty<App>(baseUrl, {
|
|
|
17
17
|
export const isNotFound = (error: unknown) =>
|
|
18
18
|
error instanceof EdenFetchError && error.status === 404
|
|
19
19
|
|
|
20
|
+
// a loader that outran the sign-out: the error boundary waits for the redirect instead of showing 500
|
|
21
|
+
export const isUnauthorized = (error: unknown) =>
|
|
22
|
+
error instanceof EdenFetchError && error.status === 401
|
|
23
|
+
|
|
20
24
|
// `throwHttpError` already turns failures into exceptions, so a null body is a contract break
|
|
21
25
|
export const unwrap = <T>({ data }: { data: T | null }): T => {
|
|
22
26
|
if (data === null) throw new Error('Empty response')
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import { createFileRoute } from '@tanstack/react-router'
|
|
2
2
|
|
|
3
3
|
import { useSession } from '@/api/auth'
|
|
4
|
-
// realtime:start
|
|
5
|
-
import { liveSnapshotQuery } from '@/api/live'
|
|
6
|
-
import { Dashboard } from '@/components/dashboard/dashboard'
|
|
7
|
-
// realtime:end
|
|
8
4
|
|
|
9
5
|
export const Route = createFileRoute('/_authenticated/')({
|
|
10
|
-
// realtime:start
|
|
11
|
-
loader: ({ context }) => context.queryClient.query({ ...liveSnapshotQuery, staleTime: 'static' }),
|
|
12
|
-
// realtime:end
|
|
13
6
|
component: HomePage
|
|
14
7
|
})
|
|
15
8
|
|
|
@@ -22,9 +15,6 @@ function HomePage() {
|
|
|
22
15
|
<h1 className='text-2xl font-semibold'>PLACEHOLDER</h1>
|
|
23
16
|
<p className='text-sm text-muted-foreground'>Signed in as {session?.user.email}</p>
|
|
24
17
|
</div>
|
|
25
|
-
{/* realtime:start */}
|
|
26
|
-
<Dashboard />
|
|
27
|
-
{/* realtime:end */}
|
|
28
18
|
</main>
|
|
29
19
|
)
|
|
30
20
|
}
|
|
@@ -20,6 +20,10 @@ export const api = treaty<App>(baseUrl, {
|
|
|
20
20
|
export const isNotFound = (error: unknown) =>
|
|
21
21
|
error instanceof EdenFetchError && error.status === 404
|
|
22
22
|
|
|
23
|
+
// a loader that outran the sign-out: the error boundary waits for the redirect instead of showing 500
|
|
24
|
+
export const isUnauthorized = (error: unknown) =>
|
|
25
|
+
error instanceof EdenFetchError && error.status === 401
|
|
26
|
+
|
|
23
27
|
// `throwHttpError` already turns failures into exceptions, so a null body is a contract break
|
|
24
28
|
export const unwrap = <T>({ data }: { data: T | null }): T => {
|
|
25
29
|
if (data === null) throw new Error('Empty response')
|
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'node:fs'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { parseArgs } from 'node:util'
|
|
7
|
-
import { cancel, intro, isCancel,
|
|
7
|
+
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'
|
|
8
8
|
|
|
9
9
|
import { writeGithubConfig } from './github-config.js'
|
|
10
10
|
import { filesWithMarker, stripMarkers } from './markers.js'
|
|
@@ -20,17 +20,6 @@ const ROUTERS = Object.keys(ROUTER_LABELS)
|
|
|
20
20
|
const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/
|
|
21
21
|
// web-side runtime deps the backend variant adds (the api types come in as a devDep)
|
|
22
22
|
const BACKEND_WEB_DEPS = { '@elysia/eden': '^1.4.10', 'better-auth': '^1.7.3' }
|
|
23
|
-
// optional example features; features.json lists what each one removes when it is off
|
|
24
|
-
const FEATURES = {
|
|
25
|
-
sidebar: { label: 'Sidebar shell', hint: 'collapsible nav, ⌘B, state persisted' },
|
|
26
|
-
table: {
|
|
27
|
-
label: 'Orders table',
|
|
28
|
-
hint: 'TanStack Table, URL-synced sorting, pin and drag columns'
|
|
29
|
-
},
|
|
30
|
-
realtime: { label: 'Live dashboard', hint: 'WebSocket metrics feed, recharts' }
|
|
31
|
-
}
|
|
32
|
-
const FEATURE_IDS = Object.keys(FEATURES)
|
|
33
|
-
|
|
34
23
|
// flags bypass prompts so the CLI works in CI
|
|
35
24
|
const { values: flags, positionals } = parseArgs({
|
|
36
25
|
args: process.argv.slice(2),
|
|
@@ -38,7 +27,6 @@ const { values: flags, positionals } = parseArgs({
|
|
|
38
27
|
options: {
|
|
39
28
|
pm: { type: 'string' },
|
|
40
29
|
router: { type: 'string' },
|
|
41
|
-
features: { type: 'string' },
|
|
42
30
|
backend: { type: 'boolean' },
|
|
43
31
|
'no-backend': { type: 'boolean' },
|
|
44
32
|
redis: { type: 'boolean' },
|
|
@@ -109,26 +97,6 @@ router ??= must(
|
|
|
109
97
|
})
|
|
110
98
|
)
|
|
111
99
|
|
|
112
|
-
// `--features table,realtime` or `--features none`; no flag and no TTY means everything
|
|
113
|
-
let features = flags.features
|
|
114
|
-
?.split(',')
|
|
115
|
-
.map(f => f.trim())
|
|
116
|
-
.filter(f => f && f !== 'none')
|
|
117
|
-
const unknownFeatures = features?.filter(f => !FEATURE_IDS.includes(f)) ?? []
|
|
118
|
-
if (unknownFeatures.length > 0) {
|
|
119
|
-
cancel(`Unknown feature(s) ${unknownFeatures.join(', ')}. Expected: ${FEATURE_IDS.join(', ')}`)
|
|
120
|
-
process.exit(1)
|
|
121
|
-
}
|
|
122
|
-
if (features === undefined && !process.stdin.isTTY) features = FEATURE_IDS
|
|
123
|
-
features ??= must(
|
|
124
|
-
await multiselect({
|
|
125
|
-
message: 'Example features? (space to toggle)',
|
|
126
|
-
required: false,
|
|
127
|
-
initialValues: FEATURE_IDS,
|
|
128
|
-
options: FEATURE_IDS.map(value => ({ value, ...FEATURES[value] }))
|
|
129
|
-
})
|
|
130
|
-
)
|
|
131
|
-
|
|
132
100
|
// Elysia runs on Bun, so the backend is only offered when bun is the package manager.
|
|
133
101
|
const flagPair = (on, off) => {
|
|
134
102
|
if (on) return true
|
|
@@ -372,6 +340,11 @@ const applyBackendOverlay = () => {
|
|
|
372
340
|
pkg.devDependencies.oxfmt = templatePkg.devDependencies.oxfmt
|
|
373
341
|
pkg.devDependencies.oxlint = templatePkg.devDependencies.oxlint
|
|
374
342
|
pkg.devDependencies['oxlint-tsgolint'] = templatePkg.devDependencies['oxlint-tsgolint']
|
|
343
|
+
// oxlint resolves JS plugins from the config's own directory
|
|
344
|
+
pkg.devDependencies['@shadcn/lint'] = templatePkg.devDependencies['@shadcn/lint']
|
|
345
|
+
pkg.devDependencies = Object.fromEntries(
|
|
346
|
+
Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b))
|
|
347
|
+
)
|
|
375
348
|
})
|
|
376
349
|
|
|
377
350
|
applyName(nameTargets(path.join(targetDir, 'apps', 'web')))
|
|
@@ -415,27 +388,6 @@ if (backend) {
|
|
|
415
388
|
}
|
|
416
389
|
}
|
|
417
390
|
|
|
418
|
-
// features: drop the files a deselected feature owns, then resolve its markers everywhere
|
|
419
|
-
const featureSpecs = JSON.parse(fs.readFileSync(path.join(cliRoot, 'features.json'), 'utf8'))
|
|
420
|
-
const rmAll = (base, files = []) => {
|
|
421
|
-
for (const file of files) fs.rmSync(path.join(base, file), { recursive: true, force: true })
|
|
422
|
-
}
|
|
423
|
-
for (const id of FEATURE_IDS) {
|
|
424
|
-
const on = features.includes(id)
|
|
425
|
-
const spec = featureSpecs[id][on ? 'on' : 'off'] ?? {}
|
|
426
|
-
rmAll(webDir, spec.remove)
|
|
427
|
-
if (backend) rmAll(path.join(targetDir, 'apps', 'api'), spec.apiRemove)
|
|
428
|
-
for (const [from, to] of Object.entries(spec.rename ?? {})) {
|
|
429
|
-
fs.renameSync(path.join(webDir, from), path.join(webDir, to))
|
|
430
|
-
}
|
|
431
|
-
if (spec.deps?.length) {
|
|
432
|
-
editJson(path.join(webDir, 'package.json'), pkg => {
|
|
433
|
-
for (const dep of spec.deps) delete pkg.dependencies[dep]
|
|
434
|
-
})
|
|
435
|
-
}
|
|
436
|
-
applyMarkersIn(targetDir, id, on)
|
|
437
|
-
}
|
|
438
|
-
|
|
439
391
|
if (router === 'start') {
|
|
440
392
|
fs.appendFileSync(
|
|
441
393
|
path.join(webDir, '.env.example'),
|
|
@@ -460,7 +412,7 @@ if (git) {
|
|
|
460
412
|
}
|
|
461
413
|
}
|
|
462
414
|
|
|
463
|
-
const label = `${ROUTER_LABELS[router]}${backend ? ' + Elysia/Drizzle/Better Auth' : ''}${redis ? ' + Redis' : ''}
|
|
415
|
+
const label = `${ROUTER_LABELS[router]}${backend ? ' + Elysia/Drizzle/Better Auth' : ''}${redis ? ' + Redis' : ''}`
|
|
464
416
|
const steps = backend
|
|
465
417
|
? [
|
|
466
418
|
...(inPlace ? [] : [`cd ${target}`]),
|
package/package.json
CHANGED
|
@@ -8,12 +8,6 @@ server needed.
|
|
|
8
8
|
`src/modules/<feature>/{index,service,model}.ts` — controller / plain factory
|
|
9
9
|
service / `t` schemas. Services never touch `Context`; one Elysia instance is one
|
|
10
10
|
controller.
|
|
11
|
-
- Optional example modules: `modules/orders` (server-side paging/sorting/filtering, between
|
|
12
|
-
`table` start/end markers) and `modules/live` (a WebSocket feed: `hub.ts` is a
|
|
13
|
-
per-process ring buffer + ticker, `index.ts` the `/api/live` socket and snapshot, between
|
|
14
|
-
`realtime` start/end markers). `create-cli` strips a deselected feature the same way
|
|
15
|
-
it strips Redis. Multi-instance deployments need a shared bus behind `hub.start`'s
|
|
16
|
-
broadcast function.
|
|
17
11
|
- `src/app.ts` is the Eden boundary: no Bun globals, no `process.env`, deps injected
|
|
18
12
|
via `createApp`.
|
|
19
13
|
- Wire values are JSON-shaped (`createdAt` is an ISO string, not `Date`) so Eden's
|
|
@@ -6,9 +6,6 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
|
|
6
6
|
|
|
7
7
|
import { createApp, type App } from './app'
|
|
8
8
|
import { createAuth } from './modules/auth/service'
|
|
9
|
-
// realtime:start
|
|
10
|
-
import { createLiveHub } from './modules/live/hub'
|
|
11
|
-
// realtime:end
|
|
12
9
|
import type { Db } from './db'
|
|
13
10
|
import * as schema from './db/schema'
|
|
14
11
|
|
|
@@ -25,10 +22,7 @@ beforeAll(async () => {
|
|
|
25
22
|
})
|
|
26
23
|
app = createApp({
|
|
27
24
|
db,
|
|
28
|
-
auth
|
|
29
|
-
// realtime:start
|
|
30
|
-
hub: createLiveHub({ db })
|
|
31
|
-
// realtime:end
|
|
25
|
+
auth
|
|
32
26
|
})
|
|
33
27
|
})
|
|
34
28
|
|
|
@@ -60,13 +54,6 @@ const signUp = async (email: string) => {
|
|
|
60
54
|
.join('; ')
|
|
61
55
|
}
|
|
62
56
|
|
|
63
|
-
// realtime:start
|
|
64
|
-
const userId = async (cookie: string) => {
|
|
65
|
-
const res = await json('/api/auth/get-session', { cookie })
|
|
66
|
-
return ((await res.json()) as { user: { id: string } }).user.id
|
|
67
|
-
}
|
|
68
|
-
// realtime:end
|
|
69
|
-
|
|
70
57
|
describe('app', () => {
|
|
71
58
|
it('serves health without a session', async () => {
|
|
72
59
|
const res = await json('/api/health')
|
|
@@ -91,10 +78,7 @@ describe('app', () => {
|
|
|
91
78
|
auth: {
|
|
92
79
|
handler: () => new Response(),
|
|
93
80
|
api: { getSession: async () => ({ user: { id: 'u' }, session: {} }) }
|
|
94
|
-
} as never
|
|
95
|
-
// realtime:start
|
|
96
|
-
hub: createLiveHub({ db: {} as never })
|
|
97
|
-
// realtime:end
|
|
81
|
+
} as never
|
|
98
82
|
})
|
|
99
83
|
const res = await broken.handle(new Request('http://localhost/api/notes'))
|
|
100
84
|
expect(res.status).toBe(500)
|
|
@@ -141,155 +125,3 @@ describe('notes', () => {
|
|
|
141
125
|
expect(res.status).toBe(422)
|
|
142
126
|
})
|
|
143
127
|
})
|
|
144
|
-
|
|
145
|
-
// table:start
|
|
146
|
-
describe('orders', () => {
|
|
147
|
-
it('pages, filters and sorts server-side, scoped to the owner', async () => {
|
|
148
|
-
const dave = await signUp('dave@example.com')
|
|
149
|
-
const erin = await signUp('erin@example.com')
|
|
150
|
-
const seed = [
|
|
151
|
-
{ customer: 'Zed', email: 'zed@example.com', amountCents: 300 },
|
|
152
|
-
{ customer: 'Amy', email: 'amy@example.com', amountCents: 100 },
|
|
153
|
-
{ customer: 'Bob', email: 'bob@example.com', amountCents: 200 }
|
|
154
|
-
]
|
|
155
|
-
const ids = await Promise.all(
|
|
156
|
-
seed.map(async body => {
|
|
157
|
-
const res = await json('/api/orders', {
|
|
158
|
-
method: 'POST',
|
|
159
|
-
cookie: dave,
|
|
160
|
-
body: JSON.stringify(body)
|
|
161
|
-
})
|
|
162
|
-
expect(res.status).toBe(201)
|
|
163
|
-
return ((await res.json()) as { id: string }).id
|
|
164
|
-
})
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
const sortedRes = await json('/api/orders?sort=amountCents&order=asc&pageSize=10', {
|
|
168
|
-
cookie: dave
|
|
169
|
-
})
|
|
170
|
-
expect(sortedRes.status, await sortedRes.clone().text()).toBe(200)
|
|
171
|
-
const sorted = (await sortedRes.json()) as { items: { amountCents: number }[]; total: number }
|
|
172
|
-
expect(sorted.total).toBe(3)
|
|
173
|
-
expect(sorted.items.map(o => o.amountCents)).toEqual([100, 200, 300])
|
|
174
|
-
|
|
175
|
-
const paged = (await (
|
|
176
|
-
await json('/api/orders?page=2&pageSize=10', { cookie: dave })
|
|
177
|
-
).json()) as {
|
|
178
|
-
items: unknown[]
|
|
179
|
-
}
|
|
180
|
-
expect(paged.items).toEqual([])
|
|
181
|
-
|
|
182
|
-
await json(`/api/orders/${ids[0]}`, {
|
|
183
|
-
method: 'PATCH',
|
|
184
|
-
cookie: dave,
|
|
185
|
-
body: JSON.stringify({ status: 'paid' })
|
|
186
|
-
})
|
|
187
|
-
const paid = (await (await json('/api/orders?status=paid', { cookie: dave })).json()) as {
|
|
188
|
-
items: { id: string }[]
|
|
189
|
-
}
|
|
190
|
-
expect(paid.items.map(o => o.id)).toEqual([ids[0]])
|
|
191
|
-
|
|
192
|
-
const theirs = (await (await json('/api/orders', { cookie: erin })).json()) as { total: number }
|
|
193
|
-
expect(theirs.total).toBe(0)
|
|
194
|
-
const foreign = await json(`/api/orders/${ids[0]}`, { cookie: erin })
|
|
195
|
-
expect(foreign.status).toBe(404)
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
it('rejects an unknown sort column instead of guessing', async () => {
|
|
199
|
-
const cookie = await signUp('fay@example.com')
|
|
200
|
-
const res = await json('/api/orders?sort=password', { cookie })
|
|
201
|
-
expect(res.status).toBe(422)
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
it('rejects a fractional page instead of failing at the database', async () => {
|
|
205
|
-
const cookie = await signUp('gil@example.com')
|
|
206
|
-
const res = await json('/api/orders?page=1.5', { cookie })
|
|
207
|
-
expect(res.status).toBe(422)
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
it('searches for `_` as a character, not as a wildcard', async () => {
|
|
211
|
-
const cookie = await signUp('hana@example.com')
|
|
212
|
-
await Promise.all(
|
|
213
|
-
['5_10 Ticket', '5A10 Ticket'].map(customer =>
|
|
214
|
-
json('/api/orders', {
|
|
215
|
-
method: 'POST',
|
|
216
|
-
cookie,
|
|
217
|
-
body: JSON.stringify({ customer, email: 'buyer@example.com', amountCents: 100 })
|
|
218
|
-
})
|
|
219
|
-
)
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
const res = await json('/api/orders?q=5_10', { cookie })
|
|
223
|
-
const found = (await res.json()) as { items: { customer: string }[] }
|
|
224
|
-
expect(found.items.map(o => o.customer)).toEqual(['5_10 Ticket'])
|
|
225
|
-
})
|
|
226
|
-
})
|
|
227
|
-
// table:end
|
|
228
|
-
|
|
229
|
-
// realtime:start
|
|
230
|
-
describe('live', () => {
|
|
231
|
-
it('serves the snapshot to a session and nobody else', async () => {
|
|
232
|
-
expect((await json('/api/live/snapshot')).status).toBe(401)
|
|
233
|
-
|
|
234
|
-
const cookie = await signUp('gus@example.com')
|
|
235
|
-
const res = await json('/api/live/snapshot', { cookie })
|
|
236
|
-
expect(res.status).toBe(200)
|
|
237
|
-
// the hub is shared across this file: other tests may already have posted activity
|
|
238
|
-
expect(await res.json()).toMatchObject({ metrics: [] })
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
it('a tick measures live sessions and broadcasts the frame', async () => {
|
|
242
|
-
const cookie = await signUp('hal@example.com')
|
|
243
|
-
const hub = createLiveHub({ db })
|
|
244
|
-
const frames: string[] = []
|
|
245
|
-
hub.join(await userId(cookie))
|
|
246
|
-
|
|
247
|
-
// stop before the first tick resolves: the interval must never add a second frame
|
|
248
|
-
const first = hub.start((_userId, frame) => frames.push(frame))
|
|
249
|
-
hub.stop()
|
|
250
|
-
await first
|
|
251
|
-
|
|
252
|
-
expect(frames).toHaveLength(1)
|
|
253
|
-
const event = JSON.parse(frames[0]!) as { type: string; data: { activeUsers: number } }
|
|
254
|
-
expect(event.type).toBe('metrics')
|
|
255
|
-
expect(event.data.activeUsers).toBeGreaterThan(0)
|
|
256
|
-
expect(hub.snapshot(await userId(cookie)).metrics).toEqual([event.data])
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
it('reports each viewer only their own revenue', async () => {
|
|
260
|
-
const seller = await signUp('ivy@example.com')
|
|
261
|
-
const bystander = await signUp('jo@example.com')
|
|
262
|
-
await json('/api/orders', {
|
|
263
|
-
method: 'POST',
|
|
264
|
-
cookie: seller,
|
|
265
|
-
body: JSON.stringify({ customer: 'Ivy Buyer', email: 'b@example.com', amountCents: 7_500 })
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
const hub = createLiveHub({ db })
|
|
269
|
-
const frames = new Map<string, { revenueCents: number; orders: number }>()
|
|
270
|
-
hub.join(await userId(seller))
|
|
271
|
-
hub.join(await userId(bystander))
|
|
272
|
-
const first = hub.start((id, frame) => {
|
|
273
|
-
frames.set(id, (JSON.parse(frame) as { data: { revenueCents: number; orders: number } }).data)
|
|
274
|
-
})
|
|
275
|
-
hub.stop()
|
|
276
|
-
await first
|
|
277
|
-
|
|
278
|
-
expect(frames.get(await userId(seller))).toMatchObject({ revenueCents: 7_500, orders: 1 })
|
|
279
|
-
expect(frames.get(await userId(bystander))).toMatchObject({ revenueCents: 0, orders: 0 })
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
it('keeps one tab open when a second one closes', async () => {
|
|
283
|
-
const cookie = await signUp('kit@example.com')
|
|
284
|
-
const hub = createLiveHub({ db })
|
|
285
|
-
const id = await userId(cookie)
|
|
286
|
-
hub.join(id)
|
|
287
|
-
hub.join(id)
|
|
288
|
-
hub.leave(id)
|
|
289
|
-
|
|
290
|
-
hub.activity(id, 'order', 'still watching')
|
|
291
|
-
|
|
292
|
-
expect(hub.snapshot(id).activity).toHaveLength(1)
|
|
293
|
-
})
|
|
294
|
-
})
|
|
295
|
-
// realtime:end
|
|
@@ -4,22 +4,12 @@ import { Elysia } from 'elysia'
|
|
|
4
4
|
import type { Db } from './db'
|
|
5
5
|
import { createAuthController } from './modules/auth'
|
|
6
6
|
import type { Auth } from './modules/auth/service'
|
|
7
|
-
// realtime:start
|
|
8
|
-
import { createLiveController } from './modules/live'
|
|
9
|
-
import type { LiveHub } from './modules/live/hub'
|
|
10
|
-
// realtime:end
|
|
11
7
|
import { createNotesController } from './modules/notes'
|
|
12
|
-
// table:start
|
|
13
|
-
import { createOrdersController } from './modules/orders'
|
|
14
|
-
// table:end
|
|
15
8
|
|
|
16
9
|
export type AppDeps = {
|
|
17
10
|
db: Db
|
|
18
11
|
auth: Auth
|
|
19
12
|
corsOrigins?: string[]
|
|
20
|
-
// realtime:start
|
|
21
|
-
hub: LiveHub
|
|
22
|
-
// realtime:end
|
|
23
13
|
}
|
|
24
14
|
|
|
25
15
|
// no Bun globals or `process.env`: the web app imports this type via Eden
|
|
@@ -36,20 +26,6 @@ export const createApp = (deps: AppDeps) => {
|
|
|
36
26
|
.use(createAuthController(auth))
|
|
37
27
|
.get('/api/health', () => ({ ok: true }))
|
|
38
28
|
.use(createNotesController({ db, auth }))
|
|
39
|
-
// table:start
|
|
40
|
-
.use(
|
|
41
|
-
createOrdersController({
|
|
42
|
-
db,
|
|
43
|
-
auth,
|
|
44
|
-
// realtime:start
|
|
45
|
-
hub: deps.hub
|
|
46
|
-
// realtime:end
|
|
47
|
-
})
|
|
48
|
-
)
|
|
49
|
-
// table:end
|
|
50
|
-
// realtime:start
|
|
51
|
-
.use(createLiveController({ hub: deps.hub, auth }))
|
|
52
|
-
// realtime:end
|
|
53
29
|
return app
|
|
54
30
|
}
|
|
55
31
|
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import { index, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
|
2
|
-
// table:start
|
|
3
|
-
import { integer, pgEnum } from 'drizzle-orm/pg-core'
|
|
4
|
-
// table:end
|
|
5
2
|
|
|
6
3
|
import { user } from './auth-schema'
|
|
7
4
|
|
|
@@ -22,36 +19,3 @@ export const note = pgTable(
|
|
|
22
19
|
},
|
|
23
20
|
table => [index('note_user_id_idx').on(table.userId)]
|
|
24
21
|
)
|
|
25
|
-
|
|
26
|
-
// table:start
|
|
27
|
-
export const orderStatus = pgEnum('order_status', [
|
|
28
|
-
'pending',
|
|
29
|
-
'paid',
|
|
30
|
-
'shipped',
|
|
31
|
-
'refunded',
|
|
32
|
-
'cancelled'
|
|
33
|
-
])
|
|
34
|
-
|
|
35
|
-
// Demo resource for the orders table: server-side paging, sorting and filtering.
|
|
36
|
-
export const order = pgTable(
|
|
37
|
-
'order',
|
|
38
|
-
{
|
|
39
|
-
id: text('id')
|
|
40
|
-
.primaryKey()
|
|
41
|
-
.$defaultFn(() => crypto.randomUUID()),
|
|
42
|
-
userId: text('user_id')
|
|
43
|
-
.notNull()
|
|
44
|
-
.references(() => user.id, { onDelete: 'cascade' }),
|
|
45
|
-
customer: text('customer').notNull(),
|
|
46
|
-
email: text('email').notNull(),
|
|
47
|
-
amountCents: integer('amount_cents').notNull(),
|
|
48
|
-
currency: text('currency').notNull().default('USD'),
|
|
49
|
-
status: orderStatus('status').notNull().default('pending'),
|
|
50
|
-
createdAt: timestamp('created_at').defaultNow().notNull()
|
|
51
|
-
},
|
|
52
|
-
table => [
|
|
53
|
-
index('order_user_id_idx').on(table.userId),
|
|
54
|
-
index('order_created_at_idx').on(table.createdAt)
|
|
55
|
-
]
|
|
56
|
-
)
|
|
57
|
-
// table:end
|
|
@@ -2,10 +2,6 @@ import { createApp } from './app'
|
|
|
2
2
|
import { createDb } from './db'
|
|
3
3
|
import { env } from './env'
|
|
4
4
|
import { createAuth } from './modules/auth/service'
|
|
5
|
-
// realtime:start
|
|
6
|
-
import { liveTopic } from './modules/live'
|
|
7
|
-
import { createLiveHub } from './modules/live/hub'
|
|
8
|
-
// realtime:end
|
|
9
5
|
// redis:start
|
|
10
6
|
import { createRedis } from './redis'
|
|
11
7
|
// redis:end
|
|
@@ -24,29 +20,17 @@ const auth = createAuth({
|
|
|
24
20
|
// redis:end
|
|
25
21
|
})
|
|
26
22
|
|
|
27
|
-
// realtime:start
|
|
28
|
-
const hub = createLiveHub({ db })
|
|
29
|
-
// realtime:end
|
|
30
23
|
const app = createApp({
|
|
31
24
|
db,
|
|
32
25
|
auth,
|
|
33
|
-
corsOrigins: env.CORS_ORIGINS
|
|
34
|
-
// realtime:start
|
|
35
|
-
hub
|
|
36
|
-
// realtime:end
|
|
26
|
+
corsOrigins: env.CORS_ORIGINS
|
|
37
27
|
}).listen(env.PORT)
|
|
38
|
-
// realtime:start
|
|
39
|
-
void hub.start((userId, frame) => app.server?.publish(liveTopic(userId), frame))
|
|
40
|
-
// realtime:end
|
|
41
28
|
|
|
42
29
|
console.log(`api listening on http://${app.server?.hostname}:${app.server?.port}`)
|
|
43
30
|
|
|
44
31
|
// SIGTERM on redeploy: drain in-flight requests, then release the pool
|
|
45
32
|
const shutdown = async (signal: string) => {
|
|
46
33
|
console.log(`${signal} received, shutting down`)
|
|
47
|
-
// realtime:start
|
|
48
|
-
hub.stop()
|
|
49
|
-
// realtime:end
|
|
50
34
|
await app.stop()
|
|
51
35
|
await db.$client.end({ timeout: 5 })
|
|
52
36
|
// redis:start
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"type": "command",
|
|
18
18
|
"statusMessage": "Formatting & linting changed file...",
|
|
19
19
|
"timeout": 30,
|
|
20
|
-
"command": "f=$(jq -r '.tool_input.file_path // .tool_response.filePath // empty'); cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null || exit 0; case \"$f\" in *.ts|*.tsx|*.js|*.mjs|*.cjs) node_modules/.bin/oxfmt \"$f\" >/dev/null 2>&1; node_modules/.bin/oxlint --fix \"$f\" >/dev/null 2>&1;; *.json|*.jsonc|*.md|*.css|*.html|*.yml|*.yaml) node_modules/.bin/oxfmt \"$f\" >/dev/null 2>&1;; esac; exit 0"
|
|
20
|
+
"command": "command -v jq >/dev/null || exit 0; f=$(jq -r '.tool_input.file_path // .tool_response.filePath // empty'); cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null || exit 0; case \"$f\" in *.ts|*.tsx|*.js|*.mjs|*.cjs) node_modules/.bin/oxfmt \"$f\" >/dev/null 2>&1; node_modules/.bin/oxlint --fix \"$f\" >/dev/null 2>&1;; *.json|*.jsonc|*.md|*.css|*.html|*.yml|*.yaml) node_modules/.bin/oxfmt \"$f\" >/dev/null 2>&1;; esac; exit 0"
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
}
|
|
@@ -83,6 +83,10 @@ primitives. `routes/sign-in.tsx` is the reference implementation;
|
|
|
83
83
|
Reuse the request schema from the feature's `schema.ts` as the form schema where the
|
|
84
84
|
shapes match; two schemas for one payload drift apart.
|
|
85
85
|
|
|
86
|
+
A component that calls `reset()` needs `'use no memo'`: the React Compiler memoizes
|
|
87
|
+
`register()`, so RHF never re-attaches the fields it dropped and the next submit sees
|
|
88
|
+
empty values.
|
|
89
|
+
|
|
86
90
|
## Routes
|
|
87
91
|
|
|
88
92
|
File-based under `src/routes/`.
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
3
|
"plugins": ["react", "typescript", "oxc", "unicorn", "import", "vitest"],
|
|
4
|
+
"jsPlugins": ["@shadcn/lint"],
|
|
5
|
+
"settings": {
|
|
6
|
+
"shadcn": {
|
|
7
|
+
"ui": "@/components/ui"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
4
10
|
"ignorePatterns": ["**/dist", "src/routeTree.gen.ts", "**/drizzle/**"],
|
|
5
11
|
"options": {
|
|
6
12
|
"typeAware": true
|
|
@@ -49,6 +55,12 @@
|
|
|
49
55
|
"max": 4
|
|
50
56
|
}
|
|
51
57
|
],
|
|
58
|
+
"shadcn/no-restyle": ["error", { "allow": ["layout"] }],
|
|
59
|
+
"shadcn/no-raw-colors": "error",
|
|
60
|
+
"shadcn/no-arbitrary-values": "error",
|
|
61
|
+
"shadcn/no-inline-styles": "error",
|
|
62
|
+
"shadcn/no-unknown-classes": "error",
|
|
63
|
+
"shadcn/require-static-classes": "error",
|
|
52
64
|
"vitest/require-mock-type-parameters": "off",
|
|
53
65
|
"vitest/require-to-throw-message": "off"
|
|
54
66
|
},
|
|
@@ -86,6 +98,7 @@
|
|
|
86
98
|
}
|
|
87
99
|
},
|
|
88
100
|
{
|
|
101
|
+
// the mock backend patches `globalThis.fetch` to serve /api/*; `localStorage` stays restricted
|
|
89
102
|
"files": ["src/mocks/**"],
|
|
90
103
|
"rules": {
|
|
91
104
|
"no-restricted-globals": [
|
|
@@ -98,6 +111,7 @@
|
|
|
98
111
|
}
|
|
99
112
|
},
|
|
100
113
|
{
|
|
114
|
+
// storage-store owns `localStorage`; tests seed and assert on it directly. `fetch` stays restricted
|
|
101
115
|
"files": ["src/lib/storage-store.ts", "src/**/*.test.{ts,tsx}"],
|
|
102
116
|
"rules": {
|
|
103
117
|
"no-restricted-globals": [
|
|
@@ -110,15 +124,20 @@
|
|
|
110
124
|
}
|
|
111
125
|
},
|
|
112
126
|
{
|
|
127
|
+
// these tests exercise the ky layer itself, so they stub `fetch` and seed `localStorage`
|
|
113
128
|
"files": ["src/api/**/*.test.ts"],
|
|
114
129
|
"rules": {
|
|
115
130
|
"no-restricted-globals": "off"
|
|
116
131
|
}
|
|
117
132
|
},
|
|
118
133
|
{
|
|
134
|
+
// vendored shadcn primitives: upstream memoizes by hand and styles with arbitrary values;
|
|
135
|
+
// rewriting either loses the upgrade path
|
|
119
136
|
"files": ["src/components/ui/**"],
|
|
120
137
|
"rules": {
|
|
121
|
-
"no-restricted-imports": "off"
|
|
138
|
+
"no-restricted-imports": "off",
|
|
139
|
+
"shadcn/no-restyle": "off",
|
|
140
|
+
"shadcn/no-arbitrary-values": "off"
|
|
122
141
|
}
|
|
123
142
|
}
|
|
124
143
|
]
|
|
@@ -40,7 +40,7 @@ React 19 SPA with React Compiler on — no manual `useMemo`/`useCallback`.
|
|
|
40
40
|
|
|
41
41
|
- **Auth is not owned by React Query.** `src/api/auth/session-store.ts` is the source of truth; never mirror session into the Query cache.
|
|
42
42
|
- **All HTTP goes through the two ky instances** in `src/api/index.ts` — never raw `fetch`.
|
|
43
|
-
- **All server responses are zod-parsed** at the boundary in `src/api/<feature>/schema.ts`.
|
|
43
|
+
- **All server responses are zod-parsed** at the boundary in `src/api/<feature>/schema.ts`. Timestamps are UTC `Z` strings: `z.iso.datetime()` rejects offsets like `+02:00` by design.
|
|
44
44
|
|
|
45
45
|
For how to add an API module, a form, a route or a UI primitive, use the **project-conventions** skill.
|
|
46
46
|
|
|
@@ -21,6 +21,12 @@ test('a new note appears in the list and opens its own page', async ({ page }) =
|
|
|
21
21
|
await expect(link).toBeVisible()
|
|
22
22
|
await expect(page.getByLabel('Title')).toHaveValue('')
|
|
23
23
|
|
|
24
|
+
// the reset form must accept a second note without a reload
|
|
25
|
+
const second = `Laundry ${Date.now()}`
|
|
26
|
+
await page.getByLabel('Title').fill(second)
|
|
27
|
+
await page.getByRole('button', { name: 'Add note' }).click()
|
|
28
|
+
await expect(page.getByRole('link', { name: second })).toBeVisible()
|
|
29
|
+
|
|
24
30
|
await link.click()
|
|
25
31
|
await expect(page.getByRole('heading', { name: title })).toBeVisible()
|
|
26
32
|
await expect(page.getByText('Milk, eggs')).toBeVisible()
|
|
@@ -24,15 +24,13 @@
|
|
|
24
24
|
"@hookform/resolvers": "^5.9.1",
|
|
25
25
|
"@tanstack/react-query": "^5.102.8",
|
|
26
26
|
"@tanstack/react-router": "^1.170.32",
|
|
27
|
-
"@tanstack/react-table": "^9.2.4",
|
|
28
27
|
"class-variance-authority": "^0.7.1",
|
|
29
|
-
"cn": "^0.
|
|
28
|
+
"cn": "^0.3.0",
|
|
30
29
|
"ky": "^2.1.0",
|
|
31
30
|
"lucide-react": "^1.41.0",
|
|
32
31
|
"react": "^19.2.8",
|
|
33
32
|
"react-dom": "^19.2.8",
|
|
34
33
|
"react-hook-form": "^7.87.0",
|
|
35
|
-
"recharts": "3.8.0",
|
|
36
34
|
"zod": "^4.5.4"
|
|
37
35
|
},
|
|
38
36
|
"devDependencies": {
|
|
@@ -41,6 +39,7 @@
|
|
|
41
39
|
"@fontsource-variable/inter": "^5.3.0",
|
|
42
40
|
"@playwright/test": "^1.63.0",
|
|
43
41
|
"@rolldown/plugin-babel": "^0.2.3",
|
|
42
|
+
"@shadcn/lint": "^0.1.0",
|
|
44
43
|
"@tailwindcss/vite": "^4.3.3",
|
|
45
44
|
"@tanstack/router-cli": "^1.167.35",
|
|
46
45
|
"@tanstack/router-plugin": "^1.168.35",
|