create-top-secret-starter 0.11.0 → 0.13.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 (223) hide show
  1. package/backend-overlay/remove.json +5 -0
  2. package/backend-overlay/root/_gitignore +2 -0
  3. package/backend-overlay/web/src/api/index.ts +11 -1
  4. package/backend-overlay/web/src/api/live/index.ts +97 -0
  5. package/backend-overlay/web/src/api/orders/index.ts +65 -0
  6. package/backend-overlay/web/src/components/header-actions.tsx +30 -0
  7. package/backend-overlay/web/src/lib/query-client.test.tsx +22 -0
  8. package/backend-overlay/web/src/lib/query-client.ts +31 -11
  9. package/backend-overlay/web/src/routes/_authenticated/index.tsx +19 -54
  10. package/backend-overlay/web/src/routes/_authenticated/notes/index.tsx +65 -0
  11. package/backend-overlay/web/src/routes/_authenticated.tsx +1 -1
  12. package/backend-overlay/web-router/src/routes/sign-in.tsx +3 -3
  13. package/backend-overlay/web-router/src/routes/sign-up.tsx +3 -3
  14. package/backend-overlay/web-start/src/api/index.ts +11 -1
  15. package/backend-overlay/web-start/src/routes/sign-in.tsx +3 -3
  16. package/backend-overlay/web-start/src/routes/sign-up.tsx +3 -3
  17. package/features.json +51 -0
  18. package/github-config.js +0 -6
  19. package/index.js +73 -44
  20. package/markers.js +37 -0
  21. package/package.json +4 -2
  22. package/templates/template-api/CLAUDE.md +6 -0
  23. package/templates/template-api/drizzle/0002_dear_warstar.sql +15 -0
  24. package/templates/template-api/drizzle/meta/0002_snapshot.json +585 -0
  25. package/templates/template-api/drizzle/meta/_journal.json +7 -0
  26. package/templates/template-api/src/app.test.ts +175 -3
  27. package/templates/template-api/src/app.ts +35 -5
  28. package/templates/template-api/src/db/schema.ts +36 -0
  29. package/templates/template-api/src/index.ts +21 -1
  30. package/templates/template-api/src/modules/live/hub.ts +123 -0
  31. package/templates/template-api/src/modules/live/index.ts +30 -0
  32. package/templates/template-api/src/modules/live/model.ts +31 -0
  33. package/templates/template-api/src/modules/notes/index.ts +1 -1
  34. package/templates/template-api/src/modules/orders/index.ts +71 -0
  35. package/templates/template-api/src/modules/orders/model.ts +49 -0
  36. package/templates/template-api/src/modules/orders/service.ts +78 -0
  37. package/templates/template-router/.claude/settings.json +1 -1
  38. package/templates/template-router/.claude/skills/project-conventions/SKILL.md +41 -31
  39. package/templates/template-router/.oxfmtrc.json +1 -0
  40. package/templates/template-router/.oxlintrc.json +18 -8
  41. package/templates/template-router/CLAUDE.md +3 -7
  42. package/templates/template-router/_gitignore +2 -0
  43. package/templates/template-router/doctor.config.json +4 -0
  44. package/templates/template-router/e2e/dashboard.spec.ts +38 -0
  45. package/templates/template-router/e2e/keyboard.spec.ts +18 -8
  46. package/templates/template-router/e2e/orders.spec.ts +82 -0
  47. package/templates/template-router/e2e/utils.ts +1 -2
  48. package/templates/template-router/index.html +11 -8
  49. package/templates/template-router/package.json +13 -11
  50. package/templates/template-router/playwright.config.ts +15 -23
  51. package/templates/template-router/src/api/auth/session-store.test.ts +14 -4
  52. package/templates/template-router/src/api/index.ts +59 -28
  53. package/templates/template-router/src/api/live/feed.ts +14 -0
  54. package/templates/template-router/src/api/live/index.ts +105 -0
  55. package/templates/template-router/src/api/live/live.test.ts +67 -0
  56. package/templates/template-router/src/api/live/schema.ts +33 -0
  57. package/templates/template-router/src/api/orders/index.ts +63 -0
  58. package/templates/template-router/src/api/orders/input.ts +48 -0
  59. package/templates/template-router/src/api/orders/orders.test.ts +48 -0
  60. package/templates/template-router/src/api/orders/schema.ts +20 -0
  61. package/templates/template-router/src/api/refresh.test.ts +62 -0
  62. package/templates/template-router/src/components/app-shell.plain.tsx +29 -0
  63. package/templates/template-router/src/components/app-shell.tsx +24 -0
  64. package/templates/template-router/src/components/app-sidebar.tsx +74 -0
  65. package/templates/template-router/src/components/dashboard/activity-feed.tsx +42 -0
  66. package/templates/template-router/src/components/dashboard/dashboard.tsx +88 -0
  67. package/templates/template-router/src/components/dashboard/live-badge.tsx +23 -0
  68. package/templates/template-router/src/components/dashboard/revenue-chart.tsx +82 -0
  69. package/templates/template-router/src/components/dashboard/stat-tile.tsx +33 -0
  70. package/templates/template-router/src/components/error-screen.tsx +2 -2
  71. package/templates/template-router/src/components/header-actions.tsx +28 -0
  72. package/templates/template-router/src/components/nav.ts +17 -0
  73. package/templates/template-router/src/components/orders/create-order-dialog.tsx +114 -0
  74. package/templates/template-router/src/components/orders/order-status-badge.tsx +28 -0
  75. package/templates/template-router/src/components/orders/order-status-select.tsx +59 -0
  76. package/templates/template-router/src/components/orders/order-status.ts +11 -0
  77. package/templates/template-router/src/components/orders/orders-table.tsx +223 -0
  78. package/templates/template-router/src/components/root-error-boundary.tsx +3 -7
  79. package/templates/template-router/src/components/ui/badge.tsx +48 -0
  80. package/templates/template-router/src/components/ui/button.tsx +2 -2
  81. package/templates/template-router/src/components/ui/card.tsx +87 -0
  82. package/templates/template-router/src/components/ui/chart.tsx +338 -0
  83. package/templates/template-router/src/components/ui/dialog.tsx +136 -0
  84. package/templates/template-router/src/components/ui/dropdown-menu.tsx +255 -0
  85. package/templates/template-router/src/components/ui/empty.tsx +2 -2
  86. package/templates/template-router/src/components/ui/field.tsx +6 -6
  87. package/templates/template-router/src/components/ui/input.tsx +1 -1
  88. package/templates/template-router/src/components/ui/select.tsx +189 -0
  89. package/templates/template-router/src/components/ui/separator.tsx +1 -1
  90. package/templates/template-router/src/components/ui/sheet.tsx +125 -0
  91. package/templates/template-router/src/components/ui/sidebar.tsx +682 -0
  92. package/templates/template-router/src/components/ui/skeleton.tsx +13 -0
  93. package/templates/template-router/src/components/ui/table.tsx +86 -0
  94. package/templates/template-router/src/components/ui/textarea.tsx +1 -1
  95. package/templates/template-router/src/components/ui/toast.tsx +3 -3
  96. package/templates/template-router/src/components/ui/tooltip.tsx +2 -2
  97. package/templates/template-router/src/hooks/use-mobile.ts +18 -0
  98. package/templates/template-router/src/index.css +0 -7
  99. package/templates/template-router/src/lib/format.ts +39 -0
  100. package/templates/template-router/src/lib/query-client.test.tsx +21 -10
  101. package/templates/template-router/src/lib/query-client.ts +26 -15
  102. package/templates/template-router/src/lib/sidebar-store.ts +10 -0
  103. package/templates/template-router/src/lib/single-flight.test.ts +14 -3
  104. package/templates/template-router/src/lib/single-flight.ts +9 -7
  105. package/templates/template-router/src/lib/storage-store.test.ts +63 -0
  106. package/templates/template-router/src/lib/storage-store.ts +22 -7
  107. package/templates/template-router/src/mocks/live.ts +110 -0
  108. package/templates/template-router/src/mocks/orders.ts +39 -0
  109. package/templates/template-router/src/mocks/people.ts +38 -0
  110. package/templates/template-router/src/mocks/server.ts +133 -5
  111. package/templates/template-router/src/providers/index.tsx +9 -15
  112. package/templates/template-router/src/providers/theme.test.tsx +3 -1
  113. package/templates/template-router/src/providers/theme.tsx +7 -2
  114. package/templates/template-router/src/routes/-not-found.tsx +1 -1
  115. package/templates/template-router/src/routes/-pending.tsx +1 -1
  116. package/templates/template-router/src/routes/_authenticated/index.tsx +21 -4
  117. package/templates/template-router/src/routes/_authenticated/notes/$noteId.tsx +5 -5
  118. package/templates/template-router/src/routes/_authenticated/notes/index.tsx +3 -3
  119. package/templates/template-router/src/routes/_authenticated/orders/$orderId.tsx +62 -0
  120. package/templates/template-router/src/routes/_authenticated/orders/index.tsx +189 -0
  121. package/templates/template-router/src/routes/_authenticated.tsx +7 -42
  122. package/templates/template-router/src/routes/sign-in.tsx +4 -4
  123. package/templates/template-router/tsconfig.app.json +1 -1
  124. package/templates/template-router/tsconfig.node.json +3 -3
  125. package/templates/template-router/vite.config.ts +2 -15
  126. package/templates/template-router/vitest.config.ts +16 -0
  127. package/templates/template-start/.claude/settings.json +1 -1
  128. package/templates/template-start/.claude/skills/project-conventions/SKILL.md +41 -31
  129. package/templates/template-start/.oxfmtrc.json +1 -0
  130. package/templates/template-start/.oxlintrc.json +18 -8
  131. package/templates/template-start/CLAUDE.md +3 -7
  132. package/templates/template-start/_gitignore +2 -0
  133. package/templates/template-start/doctor.config.json +4 -0
  134. package/templates/template-start/e2e/dashboard.spec.ts +38 -0
  135. package/templates/template-start/e2e/keyboard.spec.ts +18 -8
  136. package/templates/template-start/e2e/orders.spec.ts +82 -0
  137. package/templates/template-start/e2e/utils.ts +1 -1
  138. package/templates/template-start/package.json +12 -10
  139. package/templates/template-start/playwright.config.ts +15 -23
  140. package/templates/template-start/src/api/auth/session-store.test.ts +14 -4
  141. package/templates/template-start/src/api/index.ts +59 -28
  142. package/templates/template-start/src/api/live/feed.ts +14 -0
  143. package/templates/template-start/src/api/live/index.ts +105 -0
  144. package/templates/template-start/src/api/live/live.test.ts +67 -0
  145. package/templates/template-start/src/api/live/schema.ts +33 -0
  146. package/templates/template-start/src/api/orders/index.ts +63 -0
  147. package/templates/template-start/src/api/orders/input.ts +48 -0
  148. package/templates/template-start/src/api/orders/orders.test.ts +48 -0
  149. package/templates/template-start/src/api/orders/schema.ts +20 -0
  150. package/templates/template-start/src/api/refresh.test.ts +62 -0
  151. package/templates/template-start/src/components/app-shell.plain.tsx +29 -0
  152. package/templates/template-start/src/components/app-shell.tsx +24 -0
  153. package/templates/template-start/src/components/app-sidebar.tsx +74 -0
  154. package/templates/template-start/src/components/dashboard/activity-feed.tsx +42 -0
  155. package/templates/template-start/src/components/dashboard/dashboard.tsx +88 -0
  156. package/templates/template-start/src/components/dashboard/live-badge.tsx +23 -0
  157. package/templates/template-start/src/components/dashboard/revenue-chart.tsx +82 -0
  158. package/templates/template-start/src/components/dashboard/stat-tile.tsx +33 -0
  159. package/templates/template-start/src/components/error-screen.tsx +2 -2
  160. package/templates/template-start/src/components/header-actions.tsx +28 -0
  161. package/templates/template-start/src/components/nav.ts +17 -0
  162. package/templates/template-start/src/components/orders/create-order-dialog.tsx +114 -0
  163. package/templates/template-start/src/components/orders/order-status-badge.tsx +28 -0
  164. package/templates/template-start/src/components/orders/order-status-select.tsx +59 -0
  165. package/templates/template-start/src/components/orders/order-status.ts +11 -0
  166. package/templates/template-start/src/components/orders/orders-table.tsx +223 -0
  167. package/templates/template-start/src/components/root-error-boundary.tsx +3 -7
  168. package/templates/template-start/src/components/ui/badge.tsx +48 -0
  169. package/templates/template-start/src/components/ui/button.tsx +2 -2
  170. package/templates/template-start/src/components/ui/card.tsx +87 -0
  171. package/templates/template-start/src/components/ui/chart.tsx +338 -0
  172. package/templates/template-start/src/components/ui/dialog.tsx +136 -0
  173. package/templates/template-start/src/components/ui/dropdown-menu.tsx +255 -0
  174. package/templates/template-start/src/components/ui/empty.tsx +2 -2
  175. package/templates/template-start/src/components/ui/field.tsx +6 -6
  176. package/templates/template-start/src/components/ui/input.tsx +1 -1
  177. package/templates/template-start/src/components/ui/select.tsx +189 -0
  178. package/templates/template-start/src/components/ui/separator.tsx +1 -1
  179. package/templates/template-start/src/components/ui/sheet.tsx +125 -0
  180. package/templates/template-start/src/components/ui/sidebar.tsx +682 -0
  181. package/templates/template-start/src/components/ui/skeleton.tsx +13 -0
  182. package/templates/template-start/src/components/ui/table.tsx +86 -0
  183. package/templates/template-start/src/components/ui/textarea.tsx +1 -1
  184. package/templates/template-start/src/components/ui/toast.tsx +3 -3
  185. package/templates/template-start/src/components/ui/tooltip.tsx +2 -2
  186. package/templates/template-start/src/hooks/use-mobile.ts +18 -0
  187. package/templates/template-start/src/index.css +0 -7
  188. package/templates/template-start/src/lib/format.ts +39 -0
  189. package/templates/template-start/src/lib/query-client.test.tsx +21 -10
  190. package/templates/template-start/src/lib/query-client.ts +26 -15
  191. package/templates/template-start/src/lib/sidebar-store.ts +10 -0
  192. package/templates/template-start/src/lib/single-flight.test.ts +14 -3
  193. package/templates/template-start/src/lib/single-flight.ts +9 -7
  194. package/templates/template-start/src/lib/storage-store.test.ts +63 -0
  195. package/templates/template-start/src/lib/storage-store.ts +27 -7
  196. package/templates/template-start/src/mocks/live.ts +110 -0
  197. package/templates/template-start/src/mocks/orders.ts +39 -0
  198. package/templates/template-start/src/mocks/people.ts +38 -0
  199. package/templates/template-start/src/mocks/server.ts +133 -5
  200. package/templates/template-start/src/providers/index.tsx +9 -15
  201. package/templates/template-start/src/providers/theme.test.tsx +3 -1
  202. package/templates/template-start/src/providers/theme.tsx +7 -2
  203. package/templates/template-start/src/routes/-not-found.tsx +1 -1
  204. package/templates/template-start/src/routes/-pending.tsx +1 -1
  205. package/templates/template-start/src/routes/__root.tsx +1 -0
  206. package/templates/template-start/src/routes/_authenticated/index.tsx +21 -4
  207. package/templates/template-start/src/routes/_authenticated/notes/$noteId.tsx +5 -5
  208. package/templates/template-start/src/routes/_authenticated/notes/index.tsx +3 -3
  209. package/templates/template-start/src/routes/_authenticated/orders/$orderId.tsx +62 -0
  210. package/templates/template-start/src/routes/_authenticated/orders/index.tsx +189 -0
  211. package/templates/template-start/src/routes/_authenticated.tsx +7 -42
  212. package/templates/template-start/src/routes/sign-in.tsx +4 -4
  213. package/templates/template-start/tsconfig.app.json +1 -1
  214. package/templates/template-start/tsconfig.node.json +3 -3
  215. package/templates/template-start/tsr.config.json +7 -0
  216. package/templates/template-start/vite.config.ts +2 -12
  217. package/templates/template-start/vitest.config.ts +16 -0
  218. package/backend-overlay/web/src/routeTree.gen.ts +0 -120
  219. package/backend-overlay/web-start/src/routeTree.gen.ts +0 -191
  220. package/templates/template-router/e2e/global-setup.ts +0 -24
  221. package/templates/template-router/src/routeTree.gen.ts +0 -145
  222. package/templates/template-start/e2e/global-setup.ts +0 -24
  223. package/templates/template-start/src/routeTree.gen.ts +0 -217
@@ -1,6 +1,11 @@
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",
4
9
  "src/api/notes/notes.test.ts",
5
10
  "src/routes/_authenticated/notes",
6
11
  "src/api/auth/schema.ts",
@@ -9,6 +9,8 @@ lerna-debug.log*
9
9
 
10
10
  node_modules
11
11
  dist
12
+ # generated by the router plugin on dev/build and by `tsr generate` in typecheck
13
+ routeTree.gen.ts
12
14
  dist-ssr
13
15
  *.local
14
16
 
@@ -1,4 +1,4 @@
1
- import { treaty } from '@elysia/eden'
1
+ import { EdenFetchError, treaty } from '@elysia/eden'
2
2
 
3
3
  import type { App } from '@repo/api'
4
4
 
@@ -12,3 +12,13 @@ export const api = treaty<App>(baseUrl, {
12
12
  fetch: { credentials: 'include' },
13
13
  throwHttpError: true
14
14
  }).api // the server's `/api` group
15
+
16
+ // route loaders turn this into `notFound()`; every variant of `api` exports the same helper
17
+ export const isNotFound = (error: unknown) =>
18
+ error instanceof EdenFetchError && error.status === 404
19
+
20
+ // `throwHttpError` already turns failures into exceptions, so a null body is a contract break
21
+ export const unwrap = <T>({ data }: { data: T | null }): T => {
22
+ if (data === null) throw new Error('Empty response')
23
+ return data
24
+ }
@@ -0,0 +1,97 @@
1
+ import { queryOptions, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
2
+ import { useEffect, useState } from 'react'
3
+
4
+ import { api, unwrap } from '@/api'
5
+
6
+ // types flow from the Elysia routes through Eden — no schema to keep in sync
7
+ export type LiveSnapshot = Awaited<ReturnType<typeof getLiveSnapshot>>
8
+ export type Metrics = LiveSnapshot['metrics'][number]
9
+ export type Activity = LiveSnapshot['activity'][number]
10
+ type LiveEvent = { type: 'metrics'; data: Metrics } | { type: 'activity'; data: Activity }
11
+
12
+ // the same bounds the server's ring buffer uses
13
+ const METRICS_WINDOW = 60
14
+ const ACTIVITY_LIMIT = 20
15
+
16
+ export const getLiveSnapshot = async () => unwrap(await api.live.snapshot.get())
17
+
18
+ // `Infinity`: the socket keeps this entry fresh; a refetch only happens after a reconnect
19
+ export const liveSnapshotQuery = queryOptions({
20
+ queryKey: ['live'],
21
+ queryFn: getLiveSnapshot,
22
+ staleTime: Infinity
23
+ })
24
+
25
+ export type LiveStatus = 'connecting' | 'live' | 'reconnecting'
26
+
27
+ const applyLiveEvent = (snapshot: LiveSnapshot, event: LiveEvent): LiveSnapshot => {
28
+ switch (event.type) {
29
+ case 'metrics':
30
+ return { ...snapshot, metrics: [...snapshot.metrics.slice(1 - METRICS_WINDOW), event.data] }
31
+ case 'activity':
32
+ return { ...snapshot, activity: [event.data, ...snapshot.activity].slice(0, ACTIVITY_LIMIT) }
33
+ }
34
+ }
35
+
36
+ const BACKOFF_MS = [1_000, 2_000, 5_000, 10_000]
37
+
38
+ type ConnectOptions = {
39
+ onEvent: (event: LiveEvent) => void
40
+ onStatus: (status: LiveStatus, reconnected: boolean) => void
41
+ }
42
+
43
+ // one socket, reconnecting with backoff; the session cookie authorises the upgrade
44
+ export const connectLive = ({ onEvent, onStatus }: ConnectOptions) => {
45
+ let socket: ReturnType<typeof api.live.subscribe> | null = null
46
+ let attempt = 0
47
+ let timer: ReturnType<typeof setTimeout> | undefined
48
+ let closed = false
49
+
50
+ const open = () => {
51
+ socket = api.live.subscribe()
52
+ socket.on('open', () => {
53
+ onStatus('live', attempt > 0)
54
+ attempt = 0
55
+ })
56
+ socket.subscribe(({ data }) => onEvent(data as LiveEvent))
57
+ socket.on('close', () => {
58
+ if (closed) return
59
+ onStatus('reconnecting', false)
60
+ timer = setTimeout(open, BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)])
61
+ attempt += 1
62
+ })
63
+ }
64
+
65
+ onStatus('connecting', false)
66
+ open()
67
+
68
+ return () => {
69
+ closed = true
70
+ clearTimeout(timer)
71
+ socket?.close()
72
+ }
73
+ }
74
+
75
+ export const useLiveFeed = () => {
76
+ const queryClient = useQueryClient()
77
+ const { data } = useSuspenseQuery(liveSnapshotQuery)
78
+ const [status, setStatus] = useState<LiveStatus>('connecting')
79
+
80
+ useEffect(
81
+ () =>
82
+ connectLive({
83
+ onEvent: event =>
84
+ queryClient.setQueryData(liveSnapshotQuery.queryKey, snapshot =>
85
+ snapshot ? applyLiveEvent(snapshot, event) : snapshot
86
+ ),
87
+ onStatus: (next, reconnected) => {
88
+ setStatus(next)
89
+ // frames missed while offline: the snapshot fills the gap
90
+ if (reconnected) void queryClient.invalidateQueries(liveSnapshotQuery)
91
+ }
92
+ }),
93
+ [queryClient]
94
+ )
95
+
96
+ return { ...data, status }
97
+ }
@@ -0,0 +1,65 @@
1
+ import { queryOptions, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
2
+
3
+ import { api, unwrap } from '@/api'
4
+ import type { CreateOrderInput, OrdersSearch, UpdateOrderStatusInput } from '@/api/orders/input'
5
+
6
+ // types flow from the Elysia routes through Eden — no schema to keep in sync
7
+ export type OrdersPage = Awaited<ReturnType<typeof getOrders>>
8
+ export type Order = OrdersPage['items'][number]
9
+
10
+ export const getOrders = async (search: OrdersSearch) =>
11
+ unwrap(await api.orders.get({ query: search }))
12
+ export const getOrder = async (id: string) => unwrap(await api.orders({ id }).get())
13
+ export const createOrder = async (input: CreateOrderInput) => unwrap(await api.orders.post(input))
14
+ export const updateOrderStatus = async ({
15
+ id,
16
+ ...input
17
+ }: UpdateOrderStatusInput & { id: string }) => unwrap(await api.orders({ id }).patch(input))
18
+
19
+ // ['orders', 'list', …] and ['orders', 'detail', …]: one prefix invalidates both
20
+ const ordersKey = ['orders'] as const
21
+ const listKey = [...ordersKey, 'list'] as const
22
+
23
+ export const ordersQuery = (search: OrdersSearch) =>
24
+ queryOptions({ queryKey: [...listKey, search], queryFn: () => getOrders(search) })
25
+ export const orderQuery = (id: string) =>
26
+ queryOptions({ queryKey: [...ordersKey, 'detail', id], queryFn: () => getOrder(id) })
27
+
28
+ export const useOrders = (search: OrdersSearch) => useSuspenseQuery(ordersQuery(search))
29
+ export const useOrder = (id: string) => useSuspenseQuery(orderQuery(id))
30
+
31
+ export const useCreateOrder = () => {
32
+ const queryClient = useQueryClient()
33
+ return useMutation({
34
+ mutationFn: createOrder,
35
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: listKey })
36
+ })
37
+ }
38
+
39
+ const patchStatus = (order: Order, id: string, status: Order['status']) =>
40
+ order.id === id ? { ...order, status } : order
41
+
42
+ // optimistic: the badge flips at once; a failure rolls every touched page and detail back
43
+ export const useUpdateOrderStatus = () => {
44
+ const queryClient = useQueryClient()
45
+ return useMutation({
46
+ mutationFn: updateOrderStatus,
47
+ onMutate: async ({ id, status }) => {
48
+ await queryClient.cancelQueries({ queryKey: ordersKey })
49
+ const lists = queryClient.getQueriesData<OrdersPage>({ queryKey: listKey })
50
+ const detail = queryClient.getQueryData(orderQuery(id).queryKey)
51
+ queryClient.setQueriesData<OrdersPage>({ queryKey: listKey }, page =>
52
+ page ? { ...page, items: page.items.map(order => patchStatus(order, id, status)) } : page
53
+ )
54
+ queryClient.setQueryData(orderQuery(id).queryKey, order =>
55
+ order ? patchStatus(order, id, status) : order
56
+ )
57
+ return { lists, detail }
58
+ },
59
+ onError: (_error, { id }, context) => {
60
+ for (const [key, page] of context?.lists ?? []) queryClient.setQueryData(key, page)
61
+ queryClient.setQueryData(orderQuery(id).queryKey, context?.detail)
62
+ },
63
+ onSettled: () => queryClient.invalidateQueries({ queryKey: ordersKey })
64
+ })
65
+ }
@@ -0,0 +1,30 @@
1
+ import { useRouter } from '@tanstack/react-router'
2
+ import { LogOut } from 'lucide-react'
3
+
4
+ import { useSession, useSignOut } from '@/api/auth'
5
+ import { ThemeToggle } from '@/components/theme-toggle'
6
+ import { Button } from '@/components/ui/button'
7
+
8
+ export const HeaderActions = () => {
9
+ const { data: session } = useSession()
10
+ const signOut = useSignOut()
11
+ const router = useRouter()
12
+
13
+ const onSignOut = async () => {
14
+ await signOut.mutateAsync()
15
+ await router.navigate({ to: '/sign-in' })
16
+ }
17
+
18
+ return (
19
+ <div className='ml-auto flex items-center gap-2 sm:gap-4'>
20
+ <span className='hidden truncate text-sm text-muted-foreground sm:inline'>
21
+ {session?.user.email}
22
+ </span>
23
+ <ThemeToggle />
24
+ <Button variant='outline' size='sm' onClick={onSignOut} disabled={signOut.isPending}>
25
+ <LogOut data-icon='inline-start' />
26
+ Sign out
27
+ </Button>
28
+ </div>
29
+ )
30
+ }
@@ -60,6 +60,20 @@ it('always toasts a failed mutation', async () => {
60
60
  expect(toasts()[0]).toHaveTextContent('nope')
61
61
  })
62
62
 
63
+ it('stays silent for a mutation that shows its own errors', async () => {
64
+ await queryClient
65
+ .getMutationCache()
66
+ .build(queryClient, {
67
+ mutationFn: () => Promise.reject(new EdenFetchError(422, { message: 'Email is taken' })),
68
+ meta: { silentError: true }
69
+ })
70
+ .execute(undefined)
71
+ .catch(() => {})
72
+ await flushToasts()
73
+
74
+ expect(toasts()).toHaveLength(0)
75
+ })
76
+
63
77
  it('does not retry a 4xx query', async () => {
64
78
  const queryFn = vi.fn(() => Promise.reject(httpError(404)))
65
79
 
@@ -68,6 +82,14 @@ it('does not retry a 4xx query', async () => {
68
82
  expect(queryFn).toHaveBeenCalledTimes(1)
69
83
  })
70
84
 
85
+ it('retries a rate-limited query', async () => {
86
+ const queryFn = vi.fn(() => Promise.reject(httpError(429)))
87
+
88
+ await queryClient.query({ queryKey: ['e'], queryFn, retryDelay: 0 }).catch(() => {})
89
+
90
+ expect(queryFn).toHaveBeenCalledTimes(3)
91
+ })
92
+
71
93
  it('retries a 5xx query up to the configured limit', async () => {
72
94
  const queryFn = vi.fn(() => Promise.reject(httpError(503)))
73
95
 
@@ -3,16 +3,32 @@ import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
3
3
 
4
4
  import { toast } from '@/components/ui/toast'
5
5
 
6
- const isClientError = (error: unknown) =>
7
- error instanceof EdenFetchError && error.status >= 400 && error.status < 500
8
-
9
- const getErrorMessage = (error: unknown) => {
10
- if (error instanceof EdenFetchError) {
11
- return (
12
- (error.value as { message?: string } | null)?.message ?? `Request failed (${error.status})`
13
- )
6
+ declare module '@tanstack/react-query' {
7
+ interface Register {
8
+ mutationMeta: { silentError?: boolean }
14
9
  }
15
- return error instanceof Error ? error.message : 'Something went wrong'
10
+ }
11
+
12
+ // 408 and 429 are transient despite being 4xx
13
+ const RETRYABLE_CLIENT_ERRORS = new Set([408, 429])
14
+
15
+ const isFinalError = (error: unknown) => {
16
+ if (!(error instanceof EdenFetchError)) return false
17
+ const { status } = error
18
+ return status >= 400 && status < 500 && !RETRYABLE_CLIENT_ERRORS.has(status)
19
+ }
20
+
21
+ // the server's `{ message }` beats eden's bare status
22
+ const getErrorMessage = (error: unknown): string => {
23
+ if (!(error instanceof EdenFetchError)) {
24
+ return error instanceof Error && error.message.trim() ? error.message : 'Something went wrong'
25
+ }
26
+
27
+ const message = (error.value as { message?: string } | null)?.message
28
+
29
+ return typeof message === 'string' && message.trim()
30
+ ? message
31
+ : `Request failed (${error.status})`
16
32
  }
17
33
 
18
34
  const notifyError = (error: unknown) => {
@@ -25,11 +41,15 @@ export const queryClient = new QueryClient({
25
41
  if (query.state.data !== undefined) notifyError(error)
26
42
  }
27
43
  }),
28
- mutationCache: new MutationCache({ onError: notifyError }),
44
+ mutationCache: new MutationCache({
45
+ onError: (error, _variables, _context, mutation) => {
46
+ if (!mutation.meta?.silentError) notifyError(error)
47
+ }
48
+ }),
29
49
  defaultOptions: {
30
50
  queries: {
31
51
  staleTime: 60_000,
32
- retry: (failureCount, error) => !isClientError(error) && failureCount < 2
52
+ retry: (failureCount, error) => !isFinalError(error) && failureCount < 2
33
53
  },
34
54
  mutations: {
35
55
  retry: false
@@ -1,65 +1,30 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
- import { Trash2 } from 'lucide-react'
3
- import { useState } from 'react'
4
2
 
5
- import { useCreateNote, useDeleteNote, useNotes, notesQuery } from '@/api/notes'
6
- import { Button } from '@/components/ui/button'
7
- import { Input } from '@/components/ui/input'
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
8
 
9
9
  export const Route = createFileRoute('/_authenticated/')({
10
- loader: ({ context }) => context.queryClient.query({ ...notesQuery, staleTime: 'static' }),
11
- component: NotesPage
10
+ // realtime:start
11
+ loader: ({ context }) => context.queryClient.query({ ...liveSnapshotQuery, staleTime: 'static' }),
12
+ // realtime:end
13
+ component: HomePage
12
14
  })
13
15
 
14
- // demo of the Drizzle → Elysia → Eden → React Query path; delete with the notes modules
15
- function NotesPage() {
16
- const notes = useNotes()
17
- const createNote = useCreateNote()
18
- const deleteNote = useDeleteNote()
19
- const [title, setTitle] = useState('')
20
-
21
- const onSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
22
- e.preventDefault()
23
- const trimmed = title.trim()
24
- if (!trimmed) return
25
- createNote.mutate(trimmed, { onSuccess: () => setTitle('') })
26
- }
16
+ function HomePage() {
17
+ const { data: session } = useSession()
27
18
 
28
19
  return (
29
- <main className='w-full max-w-md space-y-4'>
30
- <h1 className='text-2xl font-semibold'>PLACEHOLDER</h1>
31
-
32
- <form onSubmit={onSubmit} className='flex gap-2'>
33
- <Input
34
- aria-label='New note'
35
- placeholder='New note…'
36
- value={title}
37
- onChange={e => setTitle(e.target.value)}
38
- maxLength={200}
39
- />
40
- <Button type='submit' disabled={createNote.isPending}>
41
- Add
42
- </Button>
43
- </form>
44
-
45
- {notes.data?.length === 0 && <p className='text-muted-foreground text-sm'>No notes yet.</p>}
46
-
47
- <ul className='divide-y rounded-lg border'>
48
- {notes.data?.map(note => (
49
- <li key={note.id} className='flex items-center justify-between px-3 py-2'>
50
- <span className='text-sm'>{note.title}</span>
51
- <Button
52
- variant='ghost'
53
- size='icon-sm'
54
- aria-label={`Delete ${note.title}`}
55
- disabled={deleteNote.isPending}
56
- onClick={() => deleteNote.mutate(note.id)}
57
- >
58
- <Trash2 />
59
- </Button>
60
- </li>
61
- ))}
62
- </ul>
20
+ <main className='flex flex-1 flex-col gap-6'>
21
+ <div className='flex flex-col gap-1'>
22
+ <h1 className='text-2xl font-semibold'>PLACEHOLDER</h1>
23
+ <p className='text-sm text-muted-foreground'>Signed in as {session?.user.email}</p>
24
+ </div>
25
+ {/* realtime:start */}
26
+ <Dashboard />
27
+ {/* realtime:end */}
63
28
  </main>
64
29
  )
65
30
  }
@@ -0,0 +1,65 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { Trash2 } from 'lucide-react'
3
+ import { useState } from 'react'
4
+
5
+ import { useCreateNote, useDeleteNote, useNotes, notesQuery } from '@/api/notes'
6
+ import { Button } from '@/components/ui/button'
7
+ import { Input } from '@/components/ui/input'
8
+
9
+ export const Route = createFileRoute('/_authenticated/notes/')({
10
+ loader: ({ context }) => context.queryClient.query({ ...notesQuery, staleTime: 'static' }),
11
+ component: NotesPage
12
+ })
13
+
14
+ // demo of the Drizzle → Elysia → Eden → React Query path; delete with the notes modules
15
+ function NotesPage() {
16
+ const notes = useNotes()
17
+ const createNote = useCreateNote()
18
+ const deleteNote = useDeleteNote()
19
+ const [title, setTitle] = useState('')
20
+
21
+ const onSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
22
+ e.preventDefault()
23
+ const trimmed = title.trim()
24
+ if (!trimmed) return
25
+ createNote.mutate(trimmed, { onSuccess: () => setTitle('') })
26
+ }
27
+
28
+ return (
29
+ <main className='mx-auto w-full max-w-md space-y-4'>
30
+ <h1 className='text-2xl font-semibold'>Notes</h1>
31
+
32
+ <form onSubmit={onSubmit} className='flex gap-2'>
33
+ <Input
34
+ aria-label='New note'
35
+ placeholder='New note…'
36
+ value={title}
37
+ onChange={e => setTitle(e.target.value)}
38
+ maxLength={200}
39
+ />
40
+ <Button type='submit' disabled={createNote.isPending}>
41
+ Add
42
+ </Button>
43
+ </form>
44
+
45
+ {notes.data?.length === 0 && <p className='text-sm text-muted-foreground'>No notes yet.</p>}
46
+
47
+ <ul className='divide-y rounded-lg border'>
48
+ {notes.data?.map(note => (
49
+ <li key={note.id} className='flex items-center justify-between px-3 py-2'>
50
+ <span className='text-sm'>{note.title}</span>
51
+ <Button
52
+ variant='ghost'
53
+ size='icon-sm'
54
+ aria-label={`Delete ${note.title}`}
55
+ disabled={deleteNote.isPending}
56
+ onClick={() => deleteNote.mutate(note.id)}
57
+ >
58
+ <Trash2 />
59
+ </Button>
60
+ </li>
61
+ ))}
62
+ </ul>
63
+ </main>
64
+ )
65
+ }
@@ -25,7 +25,7 @@ function AuthenticatedLayout() {
25
25
  <>
26
26
  <header className='flex h-14 items-center justify-between border-b px-6'>
27
27
  <div className='flex items-center gap-4'>
28
- <span className='text-muted-foreground text-sm'>{session?.user.email}</span>
28
+ <span className='text-sm text-muted-foreground'>{session?.user.email}</span>
29
29
  <ThemeToggle />
30
30
  <Button variant='outline' size='sm' onClick={onSignOut} disabled={signOut.isPending}>
31
31
  <LogOut data-icon='inline-start' />
@@ -1,4 +1,4 @@
1
- import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import { Link, createFileRoute, useRouter } from '@tanstack/react-router'
3
3
  import { useForm } from 'react-hook-form'
4
4
  import * as z from 'zod/mini'
@@ -37,7 +37,7 @@ function SignInPage() {
37
37
  const signIn = useSignIn()
38
38
 
39
39
  const { register, handleSubmit, formState } = useForm({
40
- resolver: standardSchemaResolver(SignInFormSchema),
40
+ resolver: zodResolver(SignInFormSchema),
41
41
  defaultValues: { email: '', password: '' }
42
42
  })
43
43
 
@@ -74,7 +74,7 @@ function SignInPage() {
74
74
  {formState.isSubmitting ? 'Signing in…' : 'Sign in'}
75
75
  </Button>
76
76
 
77
- <p className='text-muted-foreground text-center text-sm'>
77
+ <p className='text-center text-sm text-muted-foreground'>
78
78
  No account?{' '}
79
79
  <Link to='/sign-up' search={{ redirect: redirectTo }} className='underline'>
80
80
  Sign up
@@ -1,4 +1,4 @@
1
- import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import { Link, createFileRoute, useRouter } from '@tanstack/react-router'
3
3
  import { useForm } from 'react-hook-form'
4
4
  import * as z from 'zod/mini'
@@ -40,7 +40,7 @@ function SignUpPage() {
40
40
  const signUp = useSignUp()
41
41
 
42
42
  const { register, handleSubmit, formState } = useForm({
43
- resolver: standardSchemaResolver(SignUpFormSchema),
43
+ resolver: zodResolver(SignUpFormSchema),
44
44
  defaultValues: { name: '', email: '', password: '' }
45
45
  })
46
46
 
@@ -77,7 +77,7 @@ function SignUpPage() {
77
77
  {formState.isSubmitting ? 'Creating…' : 'Sign up'}
78
78
  </Button>
79
79
 
80
- <p className='text-muted-foreground text-center text-sm'>
80
+ <p className='text-center text-sm text-muted-foreground'>
81
81
  Already have an account?{' '}
82
82
  <Link to='/sign-in' search={{ redirect: redirectTo }} className='underline'>
83
83
  Sign in
@@ -1,5 +1,5 @@
1
1
  // Start override: the SSR runtime has no `window`, so the origin cannot be read there.
2
- import { treaty } from '@elysia/eden'
2
+ import { EdenFetchError, treaty } from '@elysia/eden'
3
3
 
4
4
  import type { App } from '@repo/api'
5
5
 
@@ -15,3 +15,13 @@ export const api = treaty<App>(baseUrl, {
15
15
  fetch: { credentials: 'include' },
16
16
  throwHttpError: true
17
17
  }).api // the server's `/api` group
18
+
19
+ // route loaders turn this into `notFound()`; every variant of `api` exports the same helper
20
+ export const isNotFound = (error: unknown) =>
21
+ error instanceof EdenFetchError && error.status === 404
22
+
23
+ // `throwHttpError` already turns failures into exceptions, so a null body is a contract break
24
+ export const unwrap = <T>({ data }: { data: T | null }): T => {
25
+ if (data === null) throw new Error('Empty response')
26
+ return data
27
+ }
@@ -1,4 +1,4 @@
1
- import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import { Link, createFileRoute, useRouter } from '@tanstack/react-router'
3
3
  import { useForm } from 'react-hook-form'
4
4
  import * as z from 'zod/mini'
@@ -41,7 +41,7 @@ function SignInPage() {
41
41
  const hydrated = useHydrated()
42
42
 
43
43
  const { register, handleSubmit, formState } = useForm({
44
- resolver: standardSchemaResolver(SignInFormSchema),
44
+ resolver: zodResolver(SignInFormSchema),
45
45
  defaultValues: { email: '', password: '' }
46
46
  })
47
47
 
@@ -78,7 +78,7 @@ function SignInPage() {
78
78
  {formState.isSubmitting ? 'Signing in…' : 'Sign in'}
79
79
  </Button>
80
80
 
81
- <p className='text-muted-foreground text-center text-sm'>
81
+ <p className='text-center text-sm text-muted-foreground'>
82
82
  No account?{' '}
83
83
  <Link to='/sign-up' search={{ redirect: redirectTo }} className='underline'>
84
84
  Sign up
@@ -1,4 +1,4 @@
1
- import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import { Link, createFileRoute, useRouter } from '@tanstack/react-router'
3
3
  import { useForm } from 'react-hook-form'
4
4
  import * as z from 'zod/mini'
@@ -44,7 +44,7 @@ function SignUpPage() {
44
44
  const hydrated = useHydrated()
45
45
 
46
46
  const { register, handleSubmit, formState } = useForm({
47
- resolver: standardSchemaResolver(SignUpFormSchema),
47
+ resolver: zodResolver(SignUpFormSchema),
48
48
  defaultValues: { name: '', email: '', password: '' }
49
49
  })
50
50
 
@@ -81,7 +81,7 @@ function SignUpPage() {
81
81
  {formState.isSubmitting ? 'Creating…' : 'Sign up'}
82
82
  </Button>
83
83
 
84
- <p className='text-muted-foreground text-center text-sm'>
84
+ <p className='text-center text-sm text-muted-foreground'>
85
85
  Already have an account?{' '}
86
86
  <Link to='/sign-in' search={{ redirect: redirectTo }} className='underline'>
87
87
  Sign in
package/features.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "sidebar": {
3
+ "on": {
4
+ "remove": ["src/components/app-shell.plain.tsx"]
5
+ },
6
+ "off": {
7
+ "remove": [
8
+ "src/components/app-sidebar.tsx",
9
+ "src/components/app-shell.tsx",
10
+ "src/components/ui/sidebar.tsx",
11
+ "src/components/ui/sheet.tsx",
12
+ "src/hooks",
13
+ "src/lib/sidebar-store.ts"
14
+ ],
15
+ "rename": {
16
+ "src/components/app-shell.plain.tsx": "src/components/app-shell.tsx"
17
+ },
18
+ "deps": []
19
+ }
20
+ },
21
+ "table": {
22
+ "off": {
23
+ "remove": [
24
+ "src/api/orders",
25
+ "src/routes/_authenticated/orders",
26
+ "src/components/orders",
27
+ "src/mocks/orders.ts",
28
+ "src/components/ui/table.tsx",
29
+ "src/components/ui/dialog.tsx",
30
+ "src/components/ui/dropdown-menu.tsx",
31
+ "src/components/ui/select.tsx",
32
+ "e2e/orders.spec.ts"
33
+ ],
34
+ "deps": ["@tanstack/react-table"],
35
+ "apiRemove": ["src/modules/orders"]
36
+ }
37
+ },
38
+ "realtime": {
39
+ "off": {
40
+ "remove": [
41
+ "src/api/live",
42
+ "src/components/dashboard",
43
+ "src/mocks/live.ts",
44
+ "src/components/ui/chart.tsx",
45
+ "e2e/dashboard.spec.ts"
46
+ ],
47
+ "deps": ["recharts"],
48
+ "apiRemove": ["src/modules/live"]
49
+ }
50
+ }
51
+ }