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.
Files changed (151) hide show
  1. package/backend-overlay/remove.json +0 -5
  2. package/backend-overlay/web/src/api/index.ts +4 -0
  3. package/backend-overlay/web/src/routes/_authenticated/index.tsx +0 -10
  4. package/backend-overlay/web-start/src/api/index.ts +4 -0
  5. package/index.js +8 -57
  6. package/package.json +1 -1
  7. package/templates/template-api/CLAUDE.md +0 -6
  8. package/templates/template-api/src/app.test.ts +2 -170
  9. package/templates/template-api/src/app.ts +0 -24
  10. package/templates/template-api/src/db/schema.ts +0 -36
  11. package/templates/template-api/src/index.ts +1 -17
  12. package/templates/template-router/.claude/settings.json +1 -1
  13. package/templates/template-router/.claude/skills/project-conventions/SKILL.md +4 -0
  14. package/templates/template-router/.oxlintrc.json +27 -18
  15. package/templates/template-router/CLAUDE.md +1 -1
  16. package/templates/template-router/e2e/keyboard.spec.ts +3 -3
  17. package/templates/template-router/e2e/notes.spec.ts +6 -0
  18. package/templates/template-router/package.json +2 -3
  19. package/templates/template-router/src/api/auth/index.ts +2 -2
  20. package/templates/template-router/src/api/auth/schema.ts +7 -0
  21. package/templates/template-router/src/api/index.ts +4 -0
  22. package/templates/template-router/src/components/app-shell.tsx +24 -16
  23. package/templates/template-router/src/components/error-screen.tsx +1 -1
  24. package/templates/template-router/src/components/nav.ts +0 -7
  25. package/templates/template-router/src/components/skip-link.tsx +11 -0
  26. package/templates/template-router/src/components/theme-toggle.tsx +1 -1
  27. package/templates/template-router/src/components/ui/toast.tsx +24 -2
  28. package/templates/template-router/src/index.css +26 -41
  29. package/templates/template-router/src/lib/format.ts +2 -37
  30. package/templates/template-router/src/mocks/server.ts +0 -121
  31. package/templates/template-router/src/routes/-error.tsx +5 -0
  32. package/templates/template-router/src/routes/-not-found.tsx +1 -1
  33. package/templates/template-router/src/routes/_authenticated/index.tsx +2 -17
  34. package/templates/template-router/src/routes/_authenticated/notes/$noteId.tsx +19 -13
  35. package/templates/template-router/src/routes/_authenticated/notes/index.tsx +82 -16
  36. package/templates/template-router/src/routes/sign-in.tsx +3 -7
  37. package/templates/template-start/.claude/settings.json +1 -1
  38. package/templates/template-start/.claude/skills/project-conventions/SKILL.md +4 -0
  39. package/templates/template-start/.oxlintrc.json +27 -18
  40. package/templates/template-start/CLAUDE.md +1 -1
  41. package/templates/template-start/e2e/keyboard.spec.ts +3 -3
  42. package/templates/template-start/e2e/notes.spec.ts +6 -0
  43. package/templates/template-start/package.json +2 -3
  44. package/templates/template-start/src/api/auth/index.ts +2 -2
  45. package/templates/template-start/src/api/auth/schema.ts +7 -0
  46. package/templates/template-start/src/api/index.ts +4 -0
  47. package/templates/template-start/src/components/app-shell.tsx +24 -16
  48. package/templates/template-start/src/components/error-screen.tsx +1 -1
  49. package/templates/template-start/src/components/nav.ts +0 -7
  50. package/templates/template-start/src/components/skip-link.tsx +11 -0
  51. package/templates/template-start/src/components/theme-toggle.tsx +1 -1
  52. package/templates/template-start/src/components/ui/toast.tsx +24 -2
  53. package/templates/template-start/src/index.css +26 -41
  54. package/templates/template-start/src/lib/format.ts +2 -37
  55. package/templates/template-start/src/mocks/server.ts +0 -121
  56. package/templates/template-start/src/routes/-error.tsx +5 -0
  57. package/templates/template-start/src/routes/-not-found.tsx +1 -1
  58. package/templates/template-start/src/routes/_authenticated/index.tsx +2 -17
  59. package/templates/template-start/src/routes/_authenticated/notes/$noteId.tsx +19 -13
  60. package/templates/template-start/src/routes/_authenticated/notes/index.tsx +82 -16
  61. package/templates/template-start/src/routes/sign-in.tsx +3 -7
  62. package/backend-overlay/web/src/api/live/index.ts +0 -97
  63. package/backend-overlay/web/src/api/orders/index.ts +0 -65
  64. package/backend-overlay/web/src/components/sidebar-user.tsx +0 -33
  65. package/features.json +0 -52
  66. package/templates/template-api/src/modules/live/hub.ts +0 -123
  67. package/templates/template-api/src/modules/live/index.ts +0 -30
  68. package/templates/template-api/src/modules/live/model.ts +0 -31
  69. package/templates/template-api/src/modules/orders/index.ts +0 -71
  70. package/templates/template-api/src/modules/orders/model.ts +0 -49
  71. package/templates/template-api/src/modules/orders/service.ts +0 -78
  72. package/templates/template-router/e2e/dashboard.spec.ts +0 -38
  73. package/templates/template-router/e2e/orders.spec.ts +0 -114
  74. package/templates/template-router/src/api/live/feed.ts +0 -14
  75. package/templates/template-router/src/api/live/index.ts +0 -105
  76. package/templates/template-router/src/api/live/live.test.ts +0 -67
  77. package/templates/template-router/src/api/live/schema.ts +0 -33
  78. package/templates/template-router/src/api/orders/index.ts +0 -63
  79. package/templates/template-router/src/api/orders/input.ts +0 -48
  80. package/templates/template-router/src/api/orders/orders.test.ts +0 -48
  81. package/templates/template-router/src/api/orders/schema.ts +0 -20
  82. package/templates/template-router/src/components/app-shell.plain.tsx +0 -29
  83. package/templates/template-router/src/components/app-sidebar.tsx +0 -133
  84. package/templates/template-router/src/components/dashboard/activity-feed.tsx +0 -42
  85. package/templates/template-router/src/components/dashboard/dashboard.tsx +0 -88
  86. package/templates/template-router/src/components/dashboard/live-badge.tsx +0 -23
  87. package/templates/template-router/src/components/dashboard/revenue-chart.tsx +0 -82
  88. package/templates/template-router/src/components/dashboard/stat-tile.tsx +0 -33
  89. package/templates/template-router/src/components/orders/create-order-dialog.tsx +0 -143
  90. package/templates/template-router/src/components/orders/order-status-badge.tsx +0 -28
  91. package/templates/template-router/src/components/orders/order-status-select.tsx +0 -59
  92. package/templates/template-router/src/components/orders/order-status.ts +0 -11
  93. package/templates/template-router/src/components/orders/orders-table-store.ts +0 -50
  94. package/templates/template-router/src/components/orders/orders-table.tsx +0 -476
  95. package/templates/template-router/src/components/sidebar-user.tsx +0 -34
  96. package/templates/template-router/src/components/ui/badge.tsx +0 -48
  97. package/templates/template-router/src/components/ui/card.tsx +0 -87
  98. package/templates/template-router/src/components/ui/chart.tsx +0 -338
  99. package/templates/template-router/src/components/ui/dropdown-menu.tsx +0 -255
  100. package/templates/template-router/src/components/ui/select.tsx +0 -189
  101. package/templates/template-router/src/components/ui/sheet.tsx +0 -125
  102. package/templates/template-router/src/components/ui/sidebar.tsx +0 -682
  103. package/templates/template-router/src/components/ui/skeleton.tsx +0 -13
  104. package/templates/template-router/src/components/ui/table.tsx +0 -86
  105. package/templates/template-router/src/hooks/use-mobile.ts +0 -18
  106. package/templates/template-router/src/lib/sidebar-store.ts +0 -10
  107. package/templates/template-router/src/mocks/live.ts +0 -110
  108. package/templates/template-router/src/mocks/orders.ts +0 -39
  109. package/templates/template-router/src/mocks/people.ts +0 -38
  110. package/templates/template-router/src/routes/_authenticated/orders/$orderId.tsx +0 -62
  111. package/templates/template-router/src/routes/_authenticated/orders/index.tsx +0 -189
  112. package/templates/template-start/e2e/dashboard.spec.ts +0 -38
  113. package/templates/template-start/e2e/orders.spec.ts +0 -114
  114. package/templates/template-start/src/api/live/feed.ts +0 -14
  115. package/templates/template-start/src/api/live/index.ts +0 -105
  116. package/templates/template-start/src/api/live/live.test.ts +0 -67
  117. package/templates/template-start/src/api/live/schema.ts +0 -33
  118. package/templates/template-start/src/api/orders/index.ts +0 -63
  119. package/templates/template-start/src/api/orders/input.ts +0 -48
  120. package/templates/template-start/src/api/orders/orders.test.ts +0 -48
  121. package/templates/template-start/src/api/orders/schema.ts +0 -20
  122. package/templates/template-start/src/components/app-shell.plain.tsx +0 -29
  123. package/templates/template-start/src/components/app-sidebar.tsx +0 -133
  124. package/templates/template-start/src/components/dashboard/activity-feed.tsx +0 -42
  125. package/templates/template-start/src/components/dashboard/dashboard.tsx +0 -88
  126. package/templates/template-start/src/components/dashboard/live-badge.tsx +0 -23
  127. package/templates/template-start/src/components/dashboard/revenue-chart.tsx +0 -82
  128. package/templates/template-start/src/components/dashboard/stat-tile.tsx +0 -33
  129. package/templates/template-start/src/components/orders/create-order-dialog.tsx +0 -143
  130. package/templates/template-start/src/components/orders/order-status-badge.tsx +0 -28
  131. package/templates/template-start/src/components/orders/order-status-select.tsx +0 -59
  132. package/templates/template-start/src/components/orders/order-status.ts +0 -11
  133. package/templates/template-start/src/components/orders/orders-table-store.ts +0 -50
  134. package/templates/template-start/src/components/orders/orders-table.tsx +0 -476
  135. package/templates/template-start/src/components/sidebar-user.tsx +0 -34
  136. package/templates/template-start/src/components/ui/badge.tsx +0 -48
  137. package/templates/template-start/src/components/ui/card.tsx +0 -87
  138. package/templates/template-start/src/components/ui/chart.tsx +0 -338
  139. package/templates/template-start/src/components/ui/dropdown-menu.tsx +0 -255
  140. package/templates/template-start/src/components/ui/select.tsx +0 -189
  141. package/templates/template-start/src/components/ui/sheet.tsx +0 -125
  142. package/templates/template-start/src/components/ui/sidebar.tsx +0 -682
  143. package/templates/template-start/src/components/ui/skeleton.tsx +0 -13
  144. package/templates/template-start/src/components/ui/table.tsx +0 -86
  145. package/templates/template-start/src/hooks/use-mobile.ts +0 -18
  146. package/templates/template-start/src/lib/sidebar-store.ts +0 -10
  147. package/templates/template-start/src/mocks/live.ts +0 -110
  148. package/templates/template-start/src/mocks/orders.ts +0 -39
  149. package/templates/template-start/src/mocks/people.ts +0 -38
  150. package/templates/template-start/src/routes/_authenticated/orders/$orderId.tsx +0 -62
  151. 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, multiselect, outro, select, text } from '@clack/prompts'
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 demo home page
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' : ''}${features.length > 0 ? ` · ${features.join(', ')}` : ''}`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-top-secret-starter",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Scaffold a Vite + React 19 + TypeScript + shadcn starter with TanStack Router",
5
5
  "keywords": [
6
6
  "create",
@@ -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/`.