create-top-secret-starter 0.14.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 +8 -57
- 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 +27 -18
- package/templates/template-router/CLAUDE.md +1 -1
- package/templates/template-router/e2e/keyboard.spec.ts +3 -3
- 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 +24 -16
- 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/components/skip-link.tsx +11 -0
- package/templates/template-router/src/components/theme-toggle.tsx +1 -1
- package/templates/template-router/src/components/ui/toast.tsx +24 -2
- package/templates/template-router/src/index.css +26 -41
- 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 +2 -17
- package/templates/template-router/src/routes/_authenticated/notes/$noteId.tsx +19 -13
- package/templates/template-router/src/routes/_authenticated/notes/index.tsx +82 -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 +27 -18
- package/templates/template-start/CLAUDE.md +1 -1
- package/templates/template-start/e2e/keyboard.spec.ts +3 -3
- 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 +24 -16
- 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/components/skip-link.tsx +11 -0
- package/templates/template-start/src/components/theme-toggle.tsx +1 -1
- package/templates/template-start/src/components/ui/toast.tsx +24 -2
- package/templates/template-start/src/index.css +26 -41
- 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 +2 -17
- package/templates/template-start/src/routes/_authenticated/notes/$noteId.tsx +19 -13
- package/templates/template-start/src/routes/_authenticated/notes/index.tsx +82 -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 -52
- 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 -29
- 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 -476
- 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 -86
- 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 -189
- 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 -29
- 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 -476
- 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 -86
- 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 -189
|
@@ -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
|
|
@@ -248,11 +216,10 @@ const substitute = (file, pairs) =>
|
|
|
248
216
|
return text.replaceAll(from, to)
|
|
249
217
|
}, source)
|
|
250
218
|
)
|
|
251
|
-
// the project name lands in the document title/description and the
|
|
219
|
+
// the project name lands in the document title/description and the sidebar brand
|
|
252
220
|
const nameTargets = base => [
|
|
253
221
|
path.join(base, 'index.html'),
|
|
254
222
|
path.join(base, 'src', 'site.ts'),
|
|
255
|
-
path.join(base, 'src', 'routes', '_authenticated', 'index.tsx'),
|
|
256
223
|
path.join(base, 'src', 'components', 'app-sidebar.tsx')
|
|
257
224
|
]
|
|
258
225
|
const applyName = files => {
|
|
@@ -373,6 +340,11 @@ const applyBackendOverlay = () => {
|
|
|
373
340
|
pkg.devDependencies.oxfmt = templatePkg.devDependencies.oxfmt
|
|
374
341
|
pkg.devDependencies.oxlint = templatePkg.devDependencies.oxlint
|
|
375
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
|
+
)
|
|
376
348
|
})
|
|
377
349
|
|
|
378
350
|
applyName(nameTargets(path.join(targetDir, 'apps', 'web')))
|
|
@@ -416,27 +388,6 @@ if (backend) {
|
|
|
416
388
|
}
|
|
417
389
|
}
|
|
418
390
|
|
|
419
|
-
// features: drop the files a deselected feature owns, then resolve its markers everywhere
|
|
420
|
-
const featureSpecs = JSON.parse(fs.readFileSync(path.join(cliRoot, 'features.json'), 'utf8'))
|
|
421
|
-
const rmAll = (base, files = []) => {
|
|
422
|
-
for (const file of files) fs.rmSync(path.join(base, file), { recursive: true, force: true })
|
|
423
|
-
}
|
|
424
|
-
for (const id of FEATURE_IDS) {
|
|
425
|
-
const on = features.includes(id)
|
|
426
|
-
const spec = featureSpecs[id][on ? 'on' : 'off'] ?? {}
|
|
427
|
-
rmAll(webDir, spec.remove)
|
|
428
|
-
if (backend) rmAll(path.join(targetDir, 'apps', 'api'), spec.apiRemove)
|
|
429
|
-
for (const [from, to] of Object.entries(spec.rename ?? {})) {
|
|
430
|
-
fs.renameSync(path.join(webDir, from), path.join(webDir, to))
|
|
431
|
-
}
|
|
432
|
-
if (spec.deps?.length) {
|
|
433
|
-
editJson(path.join(webDir, 'package.json'), pkg => {
|
|
434
|
-
for (const dep of spec.deps) delete pkg.dependencies[dep]
|
|
435
|
-
})
|
|
436
|
-
}
|
|
437
|
-
applyMarkersIn(targetDir, id, on)
|
|
438
|
-
}
|
|
439
|
-
|
|
440
391
|
if (router === 'start') {
|
|
441
392
|
fs.appendFileSync(
|
|
442
393
|
path.join(webDir, '.env.example'),
|
|
@@ -461,7 +412,7 @@ if (git) {
|
|
|
461
412
|
}
|
|
462
413
|
}
|
|
463
414
|
|
|
464
|
-
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' : ''}`
|
|
465
416
|
const steps = backend
|
|
466
417
|
? [
|
|
467
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/`.
|