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