ReviewOS

also looking at this

stacks/reportshq

Expand BunPress guide and pre-E2E quality gates

#35
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
40 files +1524 -373

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/stacks/src/http/handlers.tsmodified+1-1
Changes to packages/stacks/src/http/handlers.ts
@@ -67,7 +67,7 @@ export function createHandlers(
6767 return report
6868 }
6969
70 const writable = Boolean(store.addBlock && store.saveBlock && store.removeBlock)
70 const writable = Boolean(store.addBlock && store.saveBlock && store.removeBlock && store.publish)
7171
7272 /** The block, if it belongs to this report. Both ids arrive from a browser. */
7373 const ownedBlock = async (slug: string, blockId: number) => {
packages/stacks/tests/compiler.test.tsmodified+11-0
Changes to packages/stacks/tests/compiler.test.ts
@@ -125,6 +125,17 @@ describe('the compiler', () => {
125125 expect(auckland.total).toBe(17_500)
126126 })
127127
128 it('falls back to UTC when a stored timezone is no longer valid', async () => {
129 const { compiler } = fixture()
130 const query = { model: 'order', measure: 'revenue', time: { key: 'placed' }, grain: 'day' } as const
131
132 const utc = await compiler.run(query, 'UTC')
133 const unknown = await compiler.run(query, 'Not/A_Timezone')
134
135 expect(unknown.series).toEqual(utc.series)
136 expect(unknown.total).toBe(utc.total)
137 })
138
128139 it('groups by a dimension on the same model', async () => {
129140 const { compiler } = fixture()
130141 const result = await compiler.run({
packages/stacks/tests/http.test.tsmodified+132-4
Changes to packages/stacks/tests/http.test.ts
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'bun:test'
22import { Database } from 'bun:sqlite'
33import { Compiler } from '../src/compiler'
44import { elementHtml, tagFor } from '../src/elements'
5import { createHandlers, NotFound, type ReportStore } from '../src/http/handlers'
6import { reportRoutes } from '../src/http/routes'
5import { createHandlers, NotFound, ReadOnly, readQuery, type ReportStore } from '../src/http/handlers'
6import { isNotFound, reportRoutes } from '../src/http/routes'
77import { Registry } from '../src/semantic'
88import { Runner } from '../src/runner'
9import type { RenderedBlock } from '../src/types'
9import type { Handlers, RenderedBlock } from '../src'
1010
11function harness() {
11function harness(overrides: Partial<ReportStore> = {}) {
1212 const sqlite = new Database(':memory:')
1313 sqlite.run(`CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER, created_at TEXT)`)
1414 sqlite.run(`INSERT INTO orders (total, created_at) VALUES (10000, '2026-08-03T10:00:00Z'), (5000, '2026-08-04T10:00:00Z')`)
@@ -33,12 +33,19 @@ function harness() {
3333 { id: 10, kind: 'big_number', title: 'Revenue', x: 0, y: 0, w: 4, h: 4, query: { model: 'order', measure: 'revenue' } },
3434 ],
3535 saveLayout: async (_id, blocks) => { saved.push(...blocks) },
36 ...overrides,
3637 }
3738
3839 return { handlers: createHandlers(store, runner, registry), saved }
3940}
4041
4142describe('handlers', () => {
43 it('lists the reports exposed by the store', async () => {
44 expect((await harness().handlers.index()).reports).toEqual([
45 { id: 1, name: 'Commerce', slug: 'commerce', status: 'published' },
46 ])
47 })
48
4249 it('renders a report', async () => {
4350 const { blocks } = await harness().handlers.show('commerce')
4451
@@ -78,9 +85,89 @@ describe('handlers', () => {
7885 expect(schema.models[0].measures).toEqual(['revenue'])
7986 expect(schema.grains).toContain('day')
8087 })
88
89 it('keeps code-defined reports read-only', async () => {
90 const { handlers } = harness()
91
92 expect(handlers.editable).toBe(false)
93 await expect(handlers.addBlock('commerce', 'bar')).rejects.toThrow(ReadOnly)
94 await expect(handlers.saveBlock('commerce', 10, { title: 'Changed' })).rejects.toThrow(ReadOnly)
95 await expect(handlers.removeBlock('commerce', 10)).rejects.toThrow(ReadOnly)
96 await expect(handlers.publish('commerce')).rejects.toThrow(ReadOnly)
97 })
98
99 it('does not advertise an incomplete editor as writable', async () => {
100 const { handlers } = harness({
101 addBlock: async () => ({ id: 11, kind: 'bar' }),
102 saveBlock: async () => {},
103 removeBlock: async () => {},
104 })
105
106 expect(handlers.editable).toBe(false)
107 await expect(handlers.publish('commerce')).rejects.toThrow(ReadOnly)
108 })
109
110 it('writes only known kinds, owned blocks, and editable fields', async () => {
111 const calls: Array<[string, ...unknown[]]> = []
112 const { handlers } = harness({
113 addBlock: async (reportId, kind) => {
114 calls.push(['add', reportId, kind])
115 return { id: 11, kind: kind as any }
116 },
117 saveBlock: async (reportId, blockId, patch) => { calls.push(['save', reportId, blockId, patch]) },
118 removeBlock: async (reportId, blockId) => { calls.push(['remove', reportId, blockId]) },
119 publish: async reportId => { calls.push(['publish', reportId]) },
120 })
121
122 expect(handlers.editable).toBe(true)
123 await expect(handlers.addBlock('commerce', 'script')).rejects.toThrow(NotFound)
124 expect((await handlers.addBlock('commerce', 'bar')).block.id).toBe(11)
125
126 await handlers.saveBlock('commerce', 10, {
127 id: 999,
128 kind: 'note',
129 reportId: 2,
130 title: 'Net revenue',
131 body: 'After refunds',
132 query: { model: 'order', measure: 'revenue', limit: '25' },
133 })
134 await expect(handlers.saveBlock('commerce', 999, { title: 'Foreign' })).rejects.toThrow(NotFound)
135 await handlers.removeBlock('commerce', 10)
136 expect(await handlers.publish('commerce')).toEqual({ published: true })
137
138 expect(calls).toEqual([
139 ['add', 1, 'bar'],
140 ['save', 1, 10, {
141 title: 'Net revenue',
142 body: 'After refunds',
143 query: { model: 'order', measure: 'revenue', dimension: undefined, time: undefined, grain: undefined, from: undefined, to: undefined, filters: undefined, limit: 25 },
144 }],
145 ['remove', 1, 10],
146 ['publish', 1],
147 ])
148 })
149
150 it('normalizes builder queries before they reach storage', () => {
151 expect(readQuery({ model: 7, measure: null, from: 20260801, to: '', limit: '12' })).toEqual({
152 model: '7',
153 measure: '',
154 dimension: undefined,
155 time: undefined,
156 grain: undefined,
157 from: '20260801',
158 to: undefined,
159 filters: undefined,
160 limit: 12,
161 })
162 })
81163})
82164
83165describe('routes', () => {
166 it('identifies only package not-found errors', () => {
167 expect(isNotFound(new NotFound('missing'))).toBe(true)
168 expect(isNotFound(new Error('missing'))).toBe(false)
169 })
170
84171 it('puts the fixed paths before the slug', () => {
85172 // Or a report called "schema" is unreachable and the schema endpoint is
86173 // read as a slug.
@@ -98,6 +185,47 @@ describe('routes', () => {
98185 expect(route.name.startsWith('reportshq.')).toBe(true)
99186 }
100187 })
188
189 it('forwards route parameters and bodies to the handlers', async () => {
190 const calls: Array<[string, ...unknown[]]> = []
191 const handlers: Handlers = {
192 editable: true,
193 index: async () => { calls.push(['index']); return { reports: [] } },
194 schema: () => { calls.push(['schema']); return { models: [], grains: [] } },
195 show: async (slug, published) => { calls.push(['show', slug, published]); return { report: {}, blocks: [] } },
196 download: async (slug, format) => { calls.push(['download', slug, format]); return { body: '', headers: {} } },
197 saveLayout: async (slug, blocks) => { calls.push(['layout', slug, blocks]); return { blocks: [] } },
198 addBlock: async (slug, kind) => { calls.push(['add', slug, kind]); return { block: { kind: 'note' } } },
199 saveBlock: async (slug, id, body) => { calls.push(['save', slug, id, body]); return { saved: true } },
200 removeBlock: async (slug, id) => { calls.push(['remove', slug, id]); return { removed: true } },
201 publish: async (slug) => { calls.push(['publish', slug]); return { published: true } },
202 }
203 const routes = new Map(reportRoutes(handlers).map(route => [route.name, route]))
204
205 await routes.get('reportshq.index')!.handle({})
206 await routes.get('reportshq.schema')!.handle({})
207 await routes.get('reportshq.download')!.handle({ params: { slug: 'sales', format: 'csv' } })
208 await routes.get('reportshq.draft')!.handle({ params: { slug: 'sales' } })
209 await routes.get('reportshq.blocks.add')!.handle({ params: { slug: 'sales' }, body: { kind: 'bar' } })
210 await routes.get('reportshq.blocks.save')!.handle({ params: { slug: 'sales', block: '42' }, body: { title: 'Sales' } })
211 await routes.get('reportshq.blocks.remove')!.handle({ params: { slug: 'sales', block: '42' } })
212 await routes.get('reportshq.publish')!.handle({ params: { slug: 'sales' } })
213 await routes.get('reportshq.layout')!.handle({ params: { slug: 'sales' }, body: { blocks: [{ id: 42 }] } })
214 await routes.get('reportshq.show')!.handle({ params: { slug: 'sales' } })
215
216 expect(calls).toEqual([
217 ['index'],
218 ['schema'],
219 ['download', 'sales', 'csv'],
220 ['show', 'sales', false],
221 ['add', 'sales', 'bar'],
222 ['save', 'sales', 42, { title: 'Sales' }],
223 ['remove', 'sales', 42],
224 ['publish', 'sales'],
225 ['layout', 'sales', [{ id: 42 }]],
226 ['show', 'sales', true],
227 ])
228 })
101229})
102230
103231describe('elements', () => {
packages/stacks/tests/semantic.test.tsadded+66-0
Changes to packages/stacks/tests/semantic.test.ts
@@ -0,0 +1,66 @@
1import { describe, expect, it } from 'bun:test'
2import { Registry, SemanticError } from '../src/semantic'
3
4const order = {
5 table: 'orders',
6 measures: {
7 orders: { aggregate: 'count' as const },
8 revenue: { aggregate: 'sum' as const, column: 'total' },
9 },
10 dimensions: { status: 'status' },
11 time: { placed: 'created_at' },
12 relations: {
13 customer: { table: 'customers', foreignKey: 'customer_id' },
14 },
15}
16
17describe('semantic registry', () => {
18 it('adds descriptions fluently and keeps keys deterministic', () => {
19 const registry = new Registry().describe('zebra', order).describe('alpha', order)
20
21 expect(registry.has('alpha')).toBe(true)
22 expect(registry.has('missing')).toBe(false)
23 expect(registry.keys()).toEqual(['alpha', 'zebra'])
24 })
25
26 it('allows count without a column but refuses other incomplete measures', () => {
27 const registry = new Registry({
28 order: {
29 ...order,
30 measures: { rows: { aggregate: 'count' }, broken: { aggregate: 'sum' } },
31 },
32 })
33
34 expect(registry.measure('order', 'rows')).toEqual({ aggregate: 'count' })
35 expect(() => registry.measure('order', 'broken')).toThrow(/needs a column/)
36 })
37
38 it('returns only explicitly described direct relations', () => {
39 const registry = new Registry({
40 order,
41 customer: { table: 'customers', measures: {}, dimensions: {}, time: {} },
42 product: { table: 'products', measures: {}, dimensions: {}, time: {} },
43 })
44
45 expect(registry.relation('order', 'customer')).toMatchObject({ name: 'customer', table: 'customers' })
46 expect(() => registry.relation('order', 'product')).toThrow(/no described relation/)
47 })
48
49 it('accepts only the grains the compiler implements', () => {
50 expect(['hour', 'day', 'week', 'month'].map(Registry.grain)).toEqual(['hour', 'day', 'week', 'month'])
51 expect(() => Registry.grain('quarter')).toThrow(SemanticError)
52 expect(() => Registry.grain(null)).toThrow(/not a grain/)
53 })
54
55 it('publishes the same sorted choices a builder may offer', () => {
56 const choices = new Registry({ order }).choices()
57
58 expect(choices.models).toEqual([{
59 key: 'order',
60 measures: ['orders', 'revenue'],
61 dimensions: ['status'],
62 time: ['placed'],
63 }])
64 expect(choices.grains).toEqual(['hour', 'day', 'week', 'month'])
65 })
66})