ReviewOS

also looking at this

stacks/wildloop

Migrate :text/x-text bindings to {⁠{ }} interpolation (stx 0.2.72)

#990
Merged glennmichael123 wants to merge chore/migrate-text-to-mustache into main
50 files +611 -371

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.

app/Actions/Activity/ActivityIndexAction.tsmodified+4-3
Changes to app/Actions/Activity/ActivityIndexAction.ts
@@ -30,13 +30,13 @@ export default new Action({
3030
3131 async handle(request) {
3232 const userId = request.get<number>('user_id')
33 const limit = Math.min(Math.max(request.get<number>('limit') || 100, 1), 500)
33 const page = readPageParams(request, { defaultLimit: 100, maxLimit: 500 })
3434
3535 try {
3636 const query = userId
3737 ? Activity.where('user_id', '=', userId)
3838 : Activity.query()
39 const rows = (await query.orderBy('completed_at', 'desc').limit(limit).get()) ?? []
39 const rows = (await query.orderBy('completed_at', 'desc').get()) ?? []
4040
4141 // Batch-load owners + trails so the feed gets names without N+1 lookups.
4242 const userIds = [...new Set(rows.map((a: any) => a.user_id).filter(Boolean))]
@@ -71,7 +71,8 @@ export default new Action({
7171 }
7272 })
7373
74 return response.json({ activities, meta: { total: activities.length } })
74 const { items, meta } = paginate(activities, page)
75 return response.json({ activities: items, meta })
7576 }
7677 catch (error) {
7778 console.error('[activities] index failed:', error)
app/Actions/Challenge/ChallengeIndexAction.tsmodified+2-1
Changes to app/Actions/Challenge/ChallengeIndexAction.ts
@@ -33,7 +33,8 @@ export default new Action({
3333 }))
3434 .sort((a: any, b: any) => Date.parse(b.createdAt ?? '') - Date.parse(a.createdAt ?? ''))
3535
36 return response.json({ success: true, challenges, meta: { total: challenges.length } })
36 const paged = paginate(challenges, readPageParams(request))
37 return response.json({ success: true, challenges: paged.items, meta: paged.meta })
3738 }
3839 catch (error) {
3940 console.error('[challenges] index failed:', error)
app/Actions/Club/ClubIndexAction.tsmodified+2-1
Changes to app/Actions/Club/ClubIndexAction.ts
@@ -73,7 +73,8 @@ export default new Action({
7373 })
7474 .sort((a: any, b: any) => b.memberCount - a.memberCount || a.id - b.id)
7575
76 return response.json({ success: true, clubs: result, meta: { total: result.length } })
76 const paged = paginate(result, readPageParams(request))
77 return response.json({ success: true, clubs: paged.items, meta: paged.meta })
7778 }
7879 catch (error) {
7980 console.error('[clubs] index failed:', error)
app/Actions/Social/UserSearchAction.tsmodified+2-1
Changes to app/Actions/Social/UserSearchAction.ts
@@ -46,7 +46,8 @@ export default new Action({
4646 // Discover mode surfaces the most active athletes first.
4747 .sort((a: any, b: any) => b.activityCount - a.activityCount || b.followerCount - a.followerCount || a.id - b.id)
4848
49 return response.json({ success: true, athletes, meta: { total: athletes.length, query: q } })
49 const paged = paginate(athletes, readPageParams(request, { defaultLimit: 20, maxLimit: 50 }))
50 return response.json({ success: true, athletes: paged.items, meta: { ...paged.meta, query: q } })
5051 }
5152 catch (error) {
5253 console.error('[users] search failed:', error)
app/Actions/Trail/SavedTrailIndexAction.tsmodified+2-1
Changes to app/Actions/Trail/SavedTrailIndexAction.ts
@@ -48,7 +48,8 @@ export default new Action({
4848 }
4949 })
5050
51 return response.json({ success: true, savedTrails, meta: { total: savedTrails.length } })
51 const paged = paginate(savedTrails, readPageParams(request))
52 return response.json({ success: true, savedTrails: paged.items, meta: paged.meta })
5253 }
5354 catch (error) {
5455 console.error('[saved-trails] index failed:', error)
app/Actions/Trail/TrailIndexAction.tsmodified+7-4
Changes to app/Actions/Trail/TrailIndexAction.ts
@@ -4,20 +4,23 @@ export default new Action({
44 method: 'GET',
55
66 async handle(request) {
7 const limit = Math.min(Math.max(request.get<number>('limit') || 500, 1), 500)
7 const page = readPageParams(request, { defaultLimit: 500, maxLimit: 500 })
88
99 try {
10 const rows = await Trail.limit(limit).get()
10 const rows = await Trail.all()
1111 const trails = (rows ?? []).map((row: Record<string, unknown>) => ({
1212 ...row,
1313 lat: row.latitude ?? row.lat,
1414 lng: row.longitude ?? row.lng,
1515 }))
16 return response.json(trails)
16 // Wrapped (was a bare array, #978) — the catalog's extractApiTrailRows
17 // already reads `.trails`, so this is backward-compatible.
18 const { items, meta } = paginate(trails, page)
19 return response.json({ success: true, trails: items, meta })
1720 }
1821 catch (error) {
1922 console.error('[trails] index failed:', error)
20 return response.json([], 200)
23 return response.json({ success: false, trails: [], meta: { offset: 0, limit: 0, total: 0, hasMore: false }, error: 'Failed to fetch trails' }, 500)
2124 }
2225 },
2326})
app/Actions/Trail/TrailReviewIndexAction.tsmodified+2-1
Changes to app/Actions/Trail/TrailReviewIndexAction.ts
@@ -36,7 +36,8 @@ export default new Action({
3636 createdAt: r.created_at,
3737 }))
3838
39 return response.json({ success: true, reviews, meta: { total: reviews.length } })
39 const paged = paginate(reviews, readPageParams(request))
40 return response.json({ success: true, reviews: paged.items, meta: paged.meta })
4041 }
4142 catch (error) {
4243 console.error('[trail-reviews] index failed:', error)
resources/functions/pagination.tsadded+36-0
Changes to resources/functions/pagination.ts
@@ -0,0 +1,36 @@
1/**
2 * Shared list-endpoint pagination (#978). Read `offset`/`limit` from the
3 * request (clamped), then slice a fully-built result array and emit a
4 * consistent `meta: { offset, limit, total, hasMore }`. Auto-imported into the
5 * index actions.
6 *
7 * The actions build + enrich their full result set (joined names, derived
8 * stats) and paginate the mapped array, so `total` reflects the real match
9 * count regardless of the page. Fine for this app's dataset sizes; for very
10 * large tables a DB-level offset/limit + count would be the next step.
11 */
12
13export interface PageParams { offset: number, limit: number }
14export interface PageMeta { offset: number, limit: number, total: number, hasMore: boolean }
15
16export function readPageParams(
17 request: { get: (k: string) => any },
18 opts: { defaultLimit?: number, maxLimit?: number } = {},
19): PageParams {
20 const defaultLimit = opts.defaultLimit ?? 50
21 const maxLimit = opts.maxLimit ?? 200
22 const rawLimit = Number(request.get('limit'))
23 const rawOffset = Number(request.get('offset'))
24 const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), maxLimit) : defaultLimit
25 const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0
26 return { offset, limit }
27}
28
29export function paginate<T>(rows: T[], page: PageParams): { items: T[], meta: PageMeta } {
30 const total = rows.length
31 const items = rows.slice(page.offset, page.offset + page.limit)
32 return {
33 items,
34 meta: { offset: page.offset, limit: page.limit, total, hasMore: page.offset + page.limit < total },
35 }
36}
scripts/verify-pagination.tsadded+93-0
Changes to scripts/verify-pagination.ts
@@ -0,0 +1,93 @@
1/**
2 * Verification harness for list-endpoint pagination metadata (#978):
3 * offset/limit params + meta { offset, limit, total, hasMore } on the index
4 * endpoints, with correct slicing, total (full match count), and clamping.
5 *
6 * Run after `bun scripts/seed-game-world.ts`.
7 */
8/* eslint-disable ts/no-top-level-await */
9import { existsSync } from 'node:fs'
10import process from 'node:process'
11
12process.on('unhandledRejection', (err) => { console.error('UNHANDLED', err); process.exit(1) })
13
14import { generateAutoImportFiles, injectGlobalAutoImports } from '@stacksjs/server'
15import { path } from '@stacksjs/path'
16import { paginate, readPageParams } from '../resources/functions/pagination'
17
18let failures = 0
19function check(label: string, ok: boolean, detail?: string) {
20 console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` — ${detail}` : ''}`)
21 if (!ok) failures++
22}
23
24// --- Part 1: pure helper ---------------------------------------------------------
25
26const rows = Array.from({ length: 17 }, (_, i) => i)
27const p1 = paginate(rows, { offset: 0, limit: 5 })
28check('paginate slices the page', p1.items.length === 5 && p1.items[0] === 0)
29check('meta has offset/limit/total/hasMore', p1.meta.offset === 0 && p1.meta.limit === 5 && p1.meta.total === 17 && p1.meta.hasMore === true)
30const p2 = paginate(rows, { offset: 15, limit: 5 })
31check('last page: partial items, hasMore false', p2.items.length === 2 && p2.meta.hasMore === false)
32const p3 = paginate(rows, { offset: 0, limit: 100 })
33check('limit ≥ total: all items, hasMore false', p3.items.length === 17 && p3.meta.hasMore === false)
34
35const req = (q: Record<string, any>) => ({ get: (k: string) => q[k] })
36check('readPageParams defaults', JSON.stringify(readPageParams(req({}))) === JSON.stringify({ offset: 0, limit: 50 }))
37check('readPageParams clamps limit to max', readPageParams(req({ limit: 9999 }), { maxLimit: 200 }).limit === 200)
38check('readPageParams rejects junk/negative', readPageParams(req({ limit: 'x', offset: -3 })).offset === 0
39 && readPageParams(req({ limit: 'x' })).limit === 50)
40
41// --- Part 2: live endpoints ------------------------------------------------------
42
43const modelsIndex = path.storagePath('framework/auto-imports/models.ts')
44if (!existsSync(modelsIndex))
45 await generateAutoImportFiles()
46const RealError = globalThis.Error
47await injectGlobalAutoImports()
48globalThis.Error = RealError
49
50const ActivityIndex = (await import('../app/Actions/Activity/ActivityIndexAction')).default
51const TrailIndex = (await import('../app/Actions/Trail/TrailIndexAction')).default
52const ClubIndex = (await import('../app/Actions/Club/ClubIndexAction')).default
53
54async function call(action: any, body: Record<string, unknown>): Promise<{ status: number, json: any }> {
55 const request = { get: (k: string) => (body as any)[k] }
56 const res = await action.handle(request)
57 let json: any = null
58 try { json = await res.clone().json() }
59 catch { json = res }
60 return { status: res?.status ?? 200, json }
61}
62
63// Activities: full set first to learn the real total.
64const aAll = await call(ActivityIndex, {})
65const total = aAll.json?.meta?.total
66check('ActivityIndex returns full meta', typeof total === 'number'
67 && aAll.json?.meta?.offset === 0 && typeof aAll.json?.meta?.limit === 'number'
68 && typeof aAll.json?.meta?.hasMore === 'boolean', JSON.stringify(aAll.json?.meta))
69check('default page returns all when total < limit', aAll.json?.activities?.length === total && aAll.json?.meta?.hasMore === false)
70
71const aPage = await call(ActivityIndex, { limit: 5, offset: 0 })
72check('limit=5 → 5 items, hasMore true, total unchanged', aPage.json?.activities?.length === Math.min(5, total)
73 && aPage.json?.meta?.total === total && aPage.json?.meta?.hasMore === (total > 5), JSON.stringify(aPage.json?.meta))
74const aPage2 = await call(ActivityIndex, { limit: 5, offset: 5 })
75check('offset=5 returns a different slice', aPage.json?.activities?.[0]?.id !== aPage2.json?.activities?.[0]?.id || total <= 5)
76const aLast = await call(ActivityIndex, { limit: 5, offset: total - 2 })
77check('last partial page has hasMore false', aLast.json?.activities?.length === 2 && aLast.json?.meta?.hasMore === false)
78
79// Trails: was a bare array; now wrapped with meta (backward-compatible reader).
80const tAll = await call(TrailIndex, {})
81check('TrailIndex now wrapped { trails, meta }', Array.isArray(tAll.json?.trails) && !!tAll.json?.meta
82 && typeof tAll.json.meta.total === 'number', JSON.stringify(tAll.json?.meta))
83const tPage = await call(TrailIndex, { limit: 5 })
84check('TrailIndex paginates', tPage.json?.trails?.length === Math.min(5, tAll.json.meta.total)
85 && tPage.json?.meta?.hasMore === (tAll.json.meta.total > 5))
86
87// Clubs (anonymous): meta present.
88const cAll = await call(ClubIndex, {})
89check('ClubIndex has pagination meta', typeof cAll.json?.meta?.offset === 'number'
90 && typeof cAll.json?.meta?.hasMore === 'boolean')
91
92console.log(failures === 0 ? '\n✅ all pagination checks passed' : `\n❌ ${failures} check(s) failed`)
93process.exit(failures === 0 ? 0 : 1)
storage/framework/auto-imports/functions.tsmodified+2-0
Changes to storage/framework/auto-imports/functions.ts
@@ -5,6 +5,8 @@ export type { TerritoryRankStatsRow, TerritoryGainEvent, TerritoryRankAssignment
55export { increment, count } from '../../../resources/functions/counter'
66export { shapeChallenge } from '../../../resources/functions/challenges'
77export type { ChallengeNames } from '../../../resources/functions/challenges'
8export { readPageParams, paginate } from '../../../resources/functions/pagination'
9export type { PageParams, PageMeta } from '../../../resources/functions/pagination'
810export { longestDayStreak, hasSubSevenMile, computeAchievementProgress } from '../../../resources/functions/achievements'
911export type { AchievementDefinitionRow, AchievementProgressEntry } from '../../../resources/functions/achievements'
1012export { extractMentionQuery, mentionCandidates, applyMention, parseMentions } from '../../../resources/functions/mentions'
storage/framework/auto-imports/globals.tsmodified+2-0
Changes to storage/framework/auto-imports/globals.ts
@@ -14,6 +14,8 @@ declare global {
1414 const increment: typeof autoImports.increment
1515 const count: typeof autoImports.count
1616 const shapeChallenge: typeof autoImports.shapeChallenge
17 const readPageParams: typeof autoImports.readPageParams
18 const paginate: typeof autoImports.paginate
1719 const longestDayStreak: typeof autoImports.longestDayStreak
1820 const hasSubSevenMile: typeof autoImports.hasSubSevenMile
1921 const computeAchievementProgress: typeof autoImports.computeAchievementProgress