ReviewOS

also looking at this

stacks/pet-store

umbrella: pet-store storefront fixes (rolling)

#3
Merged glennmichael123 wants to merge umbrella/pet-store into main
586 files +38082 -7487

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.

config/features.tsadded+22-0
Changes to config/features.ts
@@ -0,0 +1,22 @@
1import type { FeaturesConfig } from '@stacksjs/types'
2
3/**
4 * **Pet-store Feature Bundles**
5 *
6 * pet-store is a commerce demo app and relies on the framework's
7 * dashboard + commerce + cms bundles for its storefront, admin SPA,
8 * and content pages. We default to the kitchen-sink so the app boots
9 * with the same surface area it had before the feature-manifest
10 * landed; trim by running `./buddy <feature>:uninstall` as needed.
11 */
12export default {
13 core: true,
14 auth: true,
15 marketing: true,
16 cms: true,
17 commerce: true,
18 dashboard: true,
19 monitoring: true,
20 realtime: true,
21 queue: true,
22} satisfies FeaturesConfig
storage/framework/core/actions/src/dev/views.tsmodified+28-10
Changes to storage/framework/core/actions/src/dev/views.ts
@@ -1,4 +1,5 @@
11import { AsyncLocalStorage } from 'node:async_hooks'
2import { existsSync } from 'node:fs'
23import { config } from '@stacksjs/config'
34import { projectPath } from '@stacksjs/path'
45
@@ -185,10 +186,25 @@ async function startDefaultServer() {
185186 onRequest: async (req: Request) => {
186187 const url = new URL(req.url)
187188
188 // /api/** → API dev server. Storefront forms posting to local
189 // routes need this; without it the frontend's stx-serve answers
190 // with a 404 page since it doesn't know about bun-router actions.
191 if (url.pathname.startsWith('/api/'))
189 // Maintenance / coming-soon gate. Runs first so it can intercept
190 // both stx page renders and API-bound traffic. The gate itself
191 // allowlists `/coming-soon`, `/api/email/subscribe`, the secret
192 // bypass URL, and static assets — so the holding page renders
193 // and visitors with a magic-link cookie pass through normally.
194 const { maintenanceGate } = await import('@stacksjs/server')
195 const gated = await maintenanceGate(req)
196 if (gated)
197 return gated
198
199 // Forward to the API dev server when this request can't possibly
200 // be a stx page render. Two cases:
201 // 1. `/api/**` — the canonical API prefix.
202 // 2. Any non-GET/HEAD verb — POST/PUT/PATCH/DELETE never match
203 // a static stx page, so they always belong to bun-router.
204 // Without (2), `route.post('/subscribe', ...)` declared at the
205 // root (no /api prefix) hits stx-serve and 404s.
206 const apiMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
207 if (url.pathname.startsWith('/api/') || apiMethods.has(req.method))
192208 return proxyToApi(req, apiBase)
193209
194210 // Stash cookies + url so server-script blocks rendering this
@@ -205,13 +221,15 @@ async function startDefaultServer() {
205221}
206222
207223async function firstExistingPath(candidates: string[]): Promise<string | null> {
224 // existsSync, not `Bun.file(dir).exists()` — `Bun.file` is file-only
225 // and returns false for any directory, which would cause every
226 // candidate to fall through to the caller's default and silently
227 // break projects whose layouts/components live at the legacy paths
228 // (`resources/{layouts,components}/`) instead of the canonical
229 // `resources/views/{layouts,components}/`.
208230 for (const candidate of candidates) {
209 try {
210 const stat = await Bun.file(projectPath(candidate)).exists()
211 if (stat)
212 return candidate
213 }
214 catch { /* ignore */ }
231 if (existsSync(projectPath(candidate)))
232 return candidate
215233 }
216234 return null
217235}
storage/framework/core/buddy/src/commands/features.tsadded+135-0
Changes to storage/framework/core/buddy/src/commands/features.ts
@@ -0,0 +1,135 @@
1import type { CLI } from '@stacksjs/types'
2import process from 'node:process'
3import { projectPath } from '@stacksjs/path'
4import { ExitCode } from '@stacksjs/types'
5
6/**
7 * Feature install / uninstall commands.
8 *
9 * Each framework feature bundle (dashboard, commerce, cms, marketing,
10 * monitoring, realtime, queue) is opt-in. The default `config/features.ts`
11 * has them all set to `false` for new apps — running `./buddy <feature>:install`
12 * flips the flag to `true` and `./buddy <feature>:uninstall` flips it back.
13 *
14 * The framework loaders (`orm/index.ts` eager-load, `defaults/bootstrap.ts`
15 * route registration, action prefetch) consult these flags at boot and
16 * skip anything whose feature is off — so an app with only `core` and
17 * `auth` activated never pays the cost of importing 70+ Commerce models
18 * or registering hundreds of dashboard routes it doesn't use.
19 *
20 * Mirrors Laravel's `php artisan passport:install` / `horizon:install`
21 * pattern: features are inert dead code on disk until installed.
22 */
23
24const FEATURE_NAMES = [
25 'dashboard',
26 'commerce',
27 'cms',
28 'marketing',
29 'monitoring',
30 'realtime',
31 'queue',
32] as const
33
34type FeatureName = (typeof FEATURE_NAMES)[number]
35
36const FEATURE_DESCRIPTIONS: Record<FeatureName, string> = {
37 dashboard: 'Admin SPA shell + Activity/Log/Request/Deployment/Notification dashboards.',
38 commerce: 'Order/Cart/Product/Customer/Coupon/GiftCard/Shipping + storefront API.',
39 cms: 'Post/Page/Author/Comment/Tag models + content edit dashboards.',
40 marketing: '/api/email/subscribe, /api/contact, Campaign/EmailList/SocialPost.',
41 monitoring: 'Error model + error-tracking views and actions.',
42 realtime: 'WebSocket broadcaster + Websocket model + realtime-stats actions.',
43 queue: 'Job + FailedJob models + queue dashboard pages.',
44}
45
46/**
47 * Flip `<feature>: <value>` inside the project's `config/features.ts`
48 * (or add the line if absent). Preserves surrounding comments and the
49 * `satisfies FeaturesConfig` suffix.
50 */
51async function setFeatureFlag(feature: FeatureName, enabled: boolean): Promise<'updated' | 'unchanged' | 'created'> {
52 const path = projectPath('config/features.ts')
53 const file = Bun.file(path)
54 if (!(await file.exists())) {
55 // Project hasn't created `config/features.ts` yet. Write a fresh one
56 // with this single flag flipped — the framework defaults take care
57 // of `core` and `auth` always being active.
58 const initial = `import type { FeaturesConfig } from '@stacksjs/types'
59
60export default {
61 ${feature}: ${enabled},
62} satisfies FeaturesConfig
63`
64 await Bun.write(path, initial)
65 return 'created'
66 }
67
68 const src = await file.text()
69 const lineRegex = new RegExp(`(^\\s*${feature}\\s*:\\s*)(true|false|\\{[^}]*\\})(\\s*,?)`, 'm')
70
71 if (lineRegex.test(src)) {
72 const next = src.replace(lineRegex, `$1${enabled}$3`)
73 if (next === src) return 'unchanged'
74 await Bun.write(path, next)
75 return 'updated'
76 }
77
78 // Flag isn't in the file — insert it inside the `export default { ... }`
79 // object literal. We anchor on the opening brace so we don't trip on
80 // any other braces in the file (e.g. a satisfies-expression at the end).
81 const insertRegex = /(export default\s*\{)/
82 if (!insertRegex.test(src)) {
83 // Bail loudly — the file's been edited into a shape we can't safely
84 // mutate. The user can fix the flag by hand.
85 throw new Error(`Could not locate \`export default {\` in ${path} — please add \`${feature}: ${enabled}\` manually.`)
86 }
87 const next = src.replace(insertRegex, `$1\n ${feature}: ${enabled},`)
88 await Bun.write(path, next)
89 return 'updated'
90}
91
92function registerInstallPair(buddy: CLI, feature: FeatureName): void {
93 const desc = FEATURE_DESCRIPTIONS[feature]
94
95 buddy
96 .command(`${feature}:install`, `Activate the ${feature} feature bundle. ${desc}`)
97 .action(async () => {
98 try {
99 const outcome = await setFeatureFlag(feature, true)
100 if (outcome === 'unchanged')
101 console.log(`✓ '${feature}' is already enabled in config/features.ts`)
102 else
103 console.log(`✓ Enabled '${feature}' in config/features.ts${outcome === 'created' ? ' (created file)' : ''}`)
104 console.log(` → next ./buddy dev will boot with ${feature} loaded.`)
105 process.exit(ExitCode.Success)
106 }
107 catch (err) {
108 console.error(`✗ Failed to install ${feature}:`, err)
109 process.exit(ExitCode.FatalError)
110 }
111 })
112
113 buddy
114 .command(`${feature}:uninstall`, `Deactivate the ${feature} feature bundle.`)
115 .action(async () => {
116 try {
117 const outcome = await setFeatureFlag(feature, false)
118 if (outcome === 'unchanged')
119 console.log(`✓ '${feature}' is already disabled in config/features.ts`)
120 else
121 console.log(`✓ Disabled '${feature}' in config/features.ts`)
122 console.log(` → next ./buddy dev will boot without ${feature}.`)
123 process.exit(ExitCode.Success)
124 }
125 catch (err) {
126 console.error(`✗ Failed to uninstall ${feature}:`, err)
127 process.exit(ExitCode.FatalError)
128 }
129 })
130}
131
132export function features(buddy: CLI): void {
133 for (const feature of FEATURE_NAMES)
134 registerInstallPair(buddy, feature)
135}
storage/framework/core/buddy/src/lazy-commands.tsmodified+15-0
Changes to storage/framework/core/buddy/src/lazy-commands.ts
@@ -33,6 +33,21 @@ const commandRegistry: Record<string, CommandLoader> = {
3333 'domains': { path: './commands/domains.ts', exportName: 'domains' },
3434 'email': { path: './commands/email.ts', exportName: 'email' },
3535 'env': { path: './commands/env.ts', exportName: 'env' },
36 // Feature install / uninstall — single file registers all pairs.
37 'dashboard:install': { path: './commands/features.ts', exportName: 'features' },
38 'dashboard:uninstall': { path: './commands/features.ts', exportName: 'features' },
39 'commerce:install': { path: './commands/features.ts', exportName: 'features' },
40 'commerce:uninstall': { path: './commands/features.ts', exportName: 'features' },
41 'cms:install': { path: './commands/features.ts', exportName: 'features' },
42 'cms:uninstall': { path: './commands/features.ts', exportName: 'features' },
43 'marketing:install': { path: './commands/features.ts', exportName: 'features' },
44 'marketing:uninstall': { path: './commands/features.ts', exportName: 'features' },
45 'monitoring:install': { path: './commands/features.ts', exportName: 'features' },
46 'monitoring:uninstall': { path: './commands/features.ts', exportName: 'features' },
47 'realtime:install': { path: './commands/features.ts', exportName: 'features' },
48 'realtime:uninstall': { path: './commands/features.ts', exportName: 'features' },
49 'queue:install': { path: './commands/features.ts', exportName: 'features' },
50 'queue:uninstall': { path: './commands/features.ts', exportName: 'features' },
3651 'fresh': { path: './commands/fresh.ts', exportName: 'fresh' },
3752 'generate': { path: './commands/generate.ts', exportName: 'generate' },
3853 'http': { path: './commands/http.ts', exportName: 'http' },
storage/framework/core/orm/src/index.tsmodified+297-145
Changes to storage/framework/core/orm/src/index.ts
@@ -76,7 +76,13 @@ async function loadUserlandModel(modelName: string, subdirs: string[] = ['']): P
7676 return (await import(userPath)).default
7777 }
7878 }
79 catch { /* fall through to framework default */ }
79 catch (err) {
80 // Pre-fix this was a bare `catch {}` — a typo'd user model silently
81 // fell through to the framework default, so the dev's customizations
82 // appeared to "not be applied" with no log line indicating why.
83 // Surface the actual import error so they can fix it.
84 console.error(`[orm] Failed to import userland model ${modelName} from ${userPath} — falling back to framework default. Reason:`, err)
85 }
8086 // 2) Framework default at storage/framework/defaults/app/Models/<Name>.ts
8187 // Defaults are organised in subdirectories (commerce/, Content/,
8288 // realtime/) so each model is searched in the relevant subdir first
@@ -89,171 +95,317 @@ async function loadUserlandModel(modelName: string, subdirs: string[] = ['']): P
8995 return (await import(defaultPath)).default
9096 }
9197 }
92 catch { /* try next subdir */ }
98 catch (err) {
99 // Same fix as above for the framework-default branch — a broken
100 // default shouldn't be invisible.
101 console.error(`[orm] Failed to import framework default model ${modelName} from ${defaultPath} — trying next subdir. Reason:`, err)
102 }
93103 }
94104 return null
95105}
96106
97export const User = await loadUserlandModel('User')
107// =============================================================================
108// Lazy model exports.
109//
110// The eager `export const User = await loadUserlandModel('User')` pattern that
111// used to live here deadlocked on the ESM cycle between this module and the
112// user's model files: `app/Models/User.ts` statically imports `{ defineModel }`
113// from `@stacksjs/orm` (this file), and Bun's dynamic `await import(userPath)`
114// from inside orm's evaluation waits for User.ts to finish — which can't,
115// because it's transitively waiting on orm to finish.
116//
117// Pre-fix this only "worked" because the trait files threw TDZ during User.ts
118// evaluation, breaking the cycle by failing fast. Once the trait TDZ was fixed
119// the cycle had to actually resolve, and it couldn't.
120//
121// New pattern: schedule the model loads as a microtask that runs AFTER orm's
122// own static-import graph finishes. By the time the microtask fires, all
123// participating modules (database, validation, define-model, the traits) are
124// fully bound, and the dynamic `await import(userPath)` no longer cycles back
125// into a half-loaded module. Each export is a Proxy that forwards to the
126// loaded model once available; per-request code (HTTP actions, jobs) is
127// always called long after the microtask queue has drained, so `User.where(...)`
128// at request time works transparently. Code that touches the exports at
129// MODULE LOAD time (e.g. a top-level `const adminCount = await User.count()`
130// in someone's startup script) should `await ormReady` first.
131// =============================================================================
132
133const _loaded: Record<string, any> = {}
98134
99// Same lazy-export pattern for the two queue framework models. The CLI
100// commands `buddy queue:status`, `queue:failed`, `queue:flush`,
101// `queue:inspect`, `queue:monitor`, `queue:clear` import them as
102// `import { Job, FailedJob } from '@stacksjs/orm'`. Prefer the userland
103// publication (`app/Models/Job.ts`, dropped in by `buddy publish:model
104// Job`) so projects that customize the queue model — renamed columns,
105// extra observers, custom traits — see their version.
106export const Job = await loadUserlandModel('Job')
107export const FailedJob = await loadUserlandModel('FailedJob')
135let _readyResolve: () => void = () => {}
136/**
137 * Resolves once all framework-default models have been loaded into the
138 * lazy-export proxies. Server bootstrap code that wants to ensure model
139 * exports are populated before serving the first request should
140 * `await ormReady` early. Per-request code doesn't need to — by the
141 * time any HTTP handler runs, the microtask queue has long drained.
142 */
143export const ormReady: Promise<void> = new Promise<void>((resolve) => { _readyResolve = resolve })
144
145/**
146 * Returns a Proxy that lazily forwards every property access to the
147 * loaded model in `_loaded[name]`. Returns `undefined` before the
148 * deferred load completes — callers that need the load to finish
149 * first should `await ormReady`.
150 */
151function lazyModel(name: string): any {
152 // The Proxy target is a function so the proxy itself is callable
153 // (in case any consumer treats a model as a constructor).
154 return new Proxy(function _modelStub() {} as any, {
155 get(_target, prop) {
156 // Thenable guard — without it, `await User` would call
157 // `User.then`, which we'd forward to the underlying (often
158 // function-like bun-query-builder) value, causing JS to treat
159 // this Proxy as a Promise and infinite-loop on resolution.
160 if (prop === 'then')
161 return undefined
162 const m = _loaded[name]
163 if (m == null)
164 return undefined
165 const value = (m as any)[prop]
166 // Bind methods to the underlying model so `this` is correct
167 // when the Proxy is the receiver.
168 return typeof value === 'function' ? value.bind(m) : value
169 },
170 set(_target, prop, value) {
171 const m = _loaded[name]
172 if (m == null)
173 return false
174 ;(m as any)[prop] = value
175 return true
176 },
177 has(_target, prop) {
178 const m = _loaded[name]
179 return m == null ? false : prop in (m as any)
180 },
181 apply(_target, thisArg, args) {
182 const m = _loaded[name]
183 if (m == null || typeof m !== 'function')
184 return undefined
185 return (m as Function).apply(thisArg, args)
186 },
187 })
188}
189
190export const User: any = lazyModel('User')
191
192// Queue framework models. The CLI commands `buddy queue:status`,
193// `queue:failed`, `queue:flush`, `queue:inspect`, `queue:monitor`,
194// `queue:clear` import them as `import { Job, FailedJob } from
195// '@stacksjs/orm'`. Prefer the userland publication
196// (`app/Models/Job.ts`, dropped in by `buddy publish:model Job`) so
197// projects that customize the queue model see their version.
198//
199// Gated on the 'queue' feature flag — projects that don't run a queue
200// (most marketing sites, plain CMS apps) leave it off and skip the
201// defineModel pipeline for these two entirely. The lazyModel proxy
202// returns `undefined` for unloaded models, and the CLI queue commands
203// can check `await ormReady; if (!Job) throw …` to surface a clear
204// "run ./buddy queue:install" error.
205export const Job: any = lazyModel('Job')
206export const FailedJob: any = lazyModel('FailedJob')
108207
109208// ---------------------------------------------------------------------------
110// Auto-load every other framework model. Each name is paired with the
111// subdirectory it lives in under `storage/framework/defaults/app/Models/`.
209// Auto-load every other framework model. Each row is `[name, subdirs, feature]`:
210// - `name` : the model class, used as the file basename and the export
211// - `subdirs` : where to look under `storage/framework/defaults/app/Models/`
212// - `feature` : which feature bundle owns this model — only loaded when
213// that flag is `true` in `config/features.ts`. Apps that
214// haven't run `./buddy commerce:install` skip every entry
215// tagged 'commerce' entirely (no migrations needed, no
216// defineModel pipeline, no boot cost).
217//
112218// The root namespace is searched first (so a flattened user override wins),
113219// then the subdir for the framework default. Loaded sequentially so the
114220// schema-TDZ cycle the original `User`-only export was guarding against
115221// stays neutralised.
116222// ---------------------------------------------------------------------------
117const FRAMEWORK_MODEL_MANIFEST: Array<[name: string, subdirs: string[]]> = [
118 // Auth / core
119 ['Team', ['']],
120 ['Subscriber', ['']],
121 ['SubscriberEmail', ['']],
122 ['Subscription', ['']],
123
124 // Content (note: capital "Content/" subdir on disk)
125 ['Post', ['Content']],
126 ['Page', ['Content']],
127 ['Author', ['Content']],
128 ['Comment', ['']],
129 ['Tag', ['']],
130
131 // App / operations
132 ['Activity', ['']],
133 ['Deployment', ['']],
134 ['Release', ['']],
135 ['Notification', ['']],
136 ['Log', ['']],
137 ['Request', ['']],
138 ['Error', ['']],
139 ['Websocket', ['realtime']],
140
141 // Marketing
142 ['Campaign', ['']],
143 ['CampaignSend', ['']],
144 ['EmailList', ['']],
145 ['EmailListSubscriber', ['']],
146 ['SocialPost', ['']],
147
148 // Payments (top-level)
149 ['PaymentMethod', ['']],
150 ['PaymentProduct', ['']],
151 ['PaymentTransaction', ['']],
223const FRAMEWORK_MODEL_MANIFEST: Array<[name: string, subdirs: string[], feature: string]> = [
224 // Auth
225 ['Team', [''], 'auth'],
226 ['Subscriber', [''], 'auth'],
227 ['SubscriberEmail', [''], 'auth'],
228 ['Subscription', [''], 'auth'],
229
230 // CMS (Content subdir on disk uses the capital C)
231 ['Post', ['Content'], 'cms'],
232 ['Page', ['Content'], 'cms'],
233 ['Author', ['Content'], 'cms'],
234 ['Comment', [''], 'cms'],
235 ['Tag', [''], 'cms'],
236
237 // Dashboard (admin SPA + monitoring dashboards)
238 ['Activity', [''], 'dashboard'],
239 ['Deployment', [''], 'dashboard'],
240 ['Release', [''], 'dashboard'],
241 ['Notification', [''], 'dashboard'],
242 ['Log', [''], 'dashboard'],
243 ['Request', [''], 'dashboard'],
244
245 // Monitoring (Error model can be used standalone without the rest of dashboard)
246 ['Error', [''], 'monitoring'],
247
248 // Realtime (websocket broadcaster)
249 ['Websocket', ['realtime'], 'realtime'],
250
251 // Marketing (email lists, campaigns, social posts)
252 ['Campaign', [''], 'marketing'],
253 ['CampaignSend', [''], 'marketing'],
254 ['EmailList', [''], 'marketing'],
255 ['EmailListSubscriber', [''], 'marketing'],
256 ['SocialPost', [''], 'marketing'],
257
258 // Payments (top-level — used by commerce checkout and by standalone billing)
259 ['PaymentMethod', [''], 'commerce'],
260 ['PaymentProduct', [''], 'commerce'],
261 ['PaymentTransaction', [''], 'commerce'],
152262
153263 // Commerce
154 ['Order', ['commerce']],
155 ['OrderItem', ['commerce']],
156 ['Cart', ['commerce']],
157 ['CartItem', ['commerce']],
158 ['Customer', ['commerce']],
159 ['Product', ['commerce']],
160 ['ProductVariant', ['commerce']],
161 ['ProductUnit', ['commerce']],
162 ['Manufacturer', ['commerce']],
163 ['Category', ['commerce']],
164 ['Coupon', ['commerce']],
165 ['GiftCard', ['commerce']],
166 ['LicenseKey', ['commerce']],
167 ['Review', ['commerce']],
168 ['Receipt', ['commerce']],
169 ['PrintDevice', ['commerce']],
170 ['ShippingMethod', ['commerce']],
171 ['ShippingRate', ['commerce']],
172 ['ShippingZone', ['commerce']],
173 ['DeliveryRoute', ['commerce']],
174 ['DigitalDelivery', ['commerce']],
175 ['Driver', ['commerce']],
176 ['TaxRate', ['commerce']],
177 ['WaitlistProduct', ['commerce']],
178 ['WaitlistRestaurant', ['commerce']],
179 ['LoyaltyPoint', ['commerce']],
180 ['LoyaltyReward', ['commerce']],
181 ['Payment', ['commerce']],
182 ['Transaction', ['commerce']],
264 ['Order', ['commerce'], 'commerce'],
265 ['OrderItem', ['commerce'], 'commerce'],
266 ['Cart', ['commerce'], 'commerce'],
267 ['CartItem', ['commerce'], 'commerce'],
268 ['Customer', ['commerce'], 'commerce'],
269 ['Product', ['commerce'], 'commerce'],
270 ['ProductVariant', ['commerce'], 'commerce'],
271 ['ProductUnit', ['commerce'], 'commerce'],
272 ['Manufacturer', ['commerce'], 'commerce'],
273 ['Category', ['commerce'], 'commerce'],
274 ['Coupon', ['commerce'], 'commerce'],
275 ['GiftCard', ['commerce'], 'commerce'],
276 ['LicenseKey', ['commerce'], 'commerce'],
277 ['Review', ['commerce'], 'commerce'],
278 ['Receipt', ['commerce'], 'commerce'],
279 ['PrintDevice', ['commerce'], 'commerce'],
280 ['ShippingMethod', ['commerce'], 'commerce'],
281 ['ShippingRate', ['commerce'], 'commerce'],
282 ['ShippingZone', ['commerce'], 'commerce'],
283 ['DeliveryRoute', ['commerce'], 'commerce'],
284 ['DigitalDelivery', ['commerce'], 'commerce'],
285 ['Driver', ['commerce'], 'commerce'],
286 ['TaxRate', ['commerce'], 'commerce'],
287 ['WaitlistProduct', ['commerce'], 'commerce'],
288 ['WaitlistRestaurant', ['commerce'], 'commerce'],
289 ['LoyaltyPoint', ['commerce'], 'commerce'],
290 ['LoyaltyReward', ['commerce'], 'commerce'],
291 ['Payment', ['commerce'], 'commerce'],
292 ['Transaction', ['commerce'], 'commerce'],
183293]
184294
185const _autoLoaded: Record<string, any> = {}
186for (const [name, subdirs] of FRAMEWORK_MODEL_MANIFEST) {
187 // eslint-disable-next-line no-await-in-loop
188 const M = await loadUserlandModel(name, subdirs)
189 if (M) _autoLoaded[name] = M
190}
295// Schedule deferred loads. The microtask runs AFTER orm's own static-import
296// graph finishes — by which point @stacksjs/config (where `feature()` lives)
297// AND every user model file's static `import { defineModel } from
298// '@stacksjs/orm'` have resolved cleanly. No cycle deadlock, no TDZ flood,
299// no eager-pre-init pipeline.
300queueMicrotask(async () => {
301 try {
302 const { feature } = await import('@stacksjs/config')
303
304 // User always loads (gated on 'core' which is implicitly true). Done
305 // first because so many framework packages statically import User.
306 const userModel = await loadUserlandModel('User')
307 if (userModel) _loaded.User = userModel
308
309 if (feature('queue')) {
310 const job = await loadUserlandModel('Job')
311 if (job) _loaded.Job = job
312 const failedJob = await loadUserlandModel('FailedJob')
313 if (failedJob) _loaded.FailedJob = failedJob
314 }
315
316 for (const [name, subdirs, featureFlag] of FRAMEWORK_MODEL_MANIFEST) {
317 if (!feature(featureFlag)) continue
318 // eslint-disable-next-line no-await-in-loop
319 const M = await loadUserlandModel(name, subdirs)
320 if (M) _loaded[name] = M
321 }
322 }
323 catch (err) {
324 // A failed deferred load shouldn't hang `ormReady` forever — surface
325 // the error and resolve anyway so any waiters proceed and can report
326 // the missing model in their own context.
327 console.error('[orm] deferred model load failed:', err)
328 }
329 finally {
330 _readyResolve()
331 }
332})
333
334export const Activity: any = lazyModel('Activity')
335export const Author: any = lazyModel('Author')
336export const Campaign: any = lazyModel('Campaign')
337export const Cart: any = lazyModel('Cart')
338export const CartItem: any = lazyModel('CartItem')
339export const Category: any = lazyModel('Category')
340export const Comment: any = lazyModel('Comment')
341export const Coupon: any = lazyModel('Coupon')
342export const Customer: any = lazyModel('Customer')
343export const DeliveryRoute: any = lazyModel('DeliveryRoute')
344export const Deployment: any = lazyModel('Deployment')
345export const DigitalDelivery: any = lazyModel('DigitalDelivery')
346export const Driver: any = lazyModel('Driver')
347export const CampaignSend: any = lazyModel('CampaignSend')
348export const EmailList: any = lazyModel('EmailList')
349export const EmailListSubscriber: any = lazyModel('EmailListSubscriber')
350export const ErrorModel: any = lazyModel('Error')
351export const GiftCard: any = lazyModel('GiftCard')
352export const LicenseKey: any = lazyModel('LicenseKey')
353export const Log: any = lazyModel('Log')
354export const LoyaltyPoint: any = lazyModel('LoyaltyPoint')
355export const LoyaltyReward: any = lazyModel('LoyaltyReward')
356export const Manufacturer: any = lazyModel('Manufacturer')
357export const Notification: any = lazyModel('Notification')
358export const Order: any = lazyModel('Order')
359export const OrderItem: any = lazyModel('OrderItem')
360export const Page: any = lazyModel('Page')
361export const Payment: any = lazyModel('Payment')
362export const PaymentMethod: any = lazyModel('PaymentMethod')
363export const PaymentProduct: any = lazyModel('PaymentProduct')
364export const PaymentTransaction: any = lazyModel('PaymentTransaction')
365export const Post: any = lazyModel('Post')
366export const PrintDevice: any = lazyModel('PrintDevice')
367export const Product: any = lazyModel('Product')
368export const ProductUnit: any = lazyModel('ProductUnit')
369export const ProductVariant: any = lazyModel('ProductVariant')
370export const Receipt: any = lazyModel('Receipt')
371export const Release: any = lazyModel('Release')
372export const Request: any = lazyModel('Request')
373export const Review: any = lazyModel('Review')
374export const ShippingMethod: any = lazyModel('ShippingMethod')
375export const ShippingRate: any = lazyModel('ShippingRate')
376export const ShippingZone: any = lazyModel('ShippingZone')
377export const SocialPost: any = lazyModel('SocialPost')
378export const Subscriber: any = lazyModel('Subscriber')
379export const SubscriberEmail: any = lazyModel('SubscriberEmail')
380export const Subscription: any = lazyModel('Subscription')
381export const Tag: any = lazyModel('Tag')
382export const TaxRate: any = lazyModel('TaxRate')
383export const Team: any = lazyModel('Team')
384export const Transaction: any = lazyModel('Transaction')
385export const WaitlistProduct: any = lazyModel('WaitlistProduct')
386export const WaitlistRestaurant: any = lazyModel('WaitlistRestaurant')
387export const Websocket: any = lazyModel('Websocket')
191388
192export const Activity = _autoLoaded.Activity
193export const Author = _autoLoaded.Author
194export const Campaign = _autoLoaded.Campaign
195export const Cart = _autoLoaded.Cart
196export const CartItem = _autoLoaded.CartItem
197export const Category = _autoLoaded.Category
198export const Comment = _autoLoaded.Comment
199export const Coupon = _autoLoaded.Coupon
200export const Customer = _autoLoaded.Customer
201export const DeliveryRoute = _autoLoaded.DeliveryRoute
202export const Deployment = _autoLoaded.Deployment
203export const DigitalDelivery = _autoLoaded.DigitalDelivery
204export const Driver = _autoLoaded.Driver
205export const CampaignSend = _autoLoaded.CampaignSend
206export const EmailList = _autoLoaded.EmailList
207export const EmailListSubscriber = _autoLoaded.EmailListSubscriber
208export const ErrorModel = _autoLoaded.Error
209export const GiftCard = _autoLoaded.GiftCard
210export const LicenseKey = _autoLoaded.LicenseKey
211export const Log = _autoLoaded.Log
212export const LoyaltyPoint = _autoLoaded.LoyaltyPoint
213export const LoyaltyReward = _autoLoaded.LoyaltyReward
214export const Manufacturer = _autoLoaded.Manufacturer
215export const Notification = _autoLoaded.Notification
216export const Order = _autoLoaded.Order
217export const OrderItem = _autoLoaded.OrderItem
218export const Page = _autoLoaded.Page
219export const Payment = _autoLoaded.Payment
220export const PaymentMethod = _autoLoaded.PaymentMethod
221export const PaymentProduct = _autoLoaded.PaymentProduct
222export const PaymentTransaction = _autoLoaded.PaymentTransaction
223export const Post = _autoLoaded.Post
224export const PrintDevice = _autoLoaded.PrintDevice
225export const Product = _autoLoaded.Product
226export const ProductUnit = _autoLoaded.ProductUnit
227export const ProductVariant = _autoLoaded.ProductVariant
228export const Receipt = _autoLoaded.Receipt
229export const Release = _autoLoaded.Release
230export const Request = _autoLoaded.Request
231export const Review = _autoLoaded.Review
232export const ShippingMethod = _autoLoaded.ShippingMethod
233export const ShippingRate = _autoLoaded.ShippingRate
234export const ShippingZone = _autoLoaded.ShippingZone
235export const SocialPost = _autoLoaded.SocialPost
236export const Subscriber = _autoLoaded.Subscriber
237export const SubscriberEmail = _autoLoaded.SubscriberEmail
238export const Subscription = _autoLoaded.Subscription
239export const Tag = _autoLoaded.Tag
240export const TaxRate = _autoLoaded.TaxRate
241export const Team = _autoLoaded.Team
242export const Transaction = _autoLoaded.Transaction
243export const WaitlistProduct = _autoLoaded.WaitlistProduct
244export const WaitlistRestaurant = _autoLoaded.WaitlistRestaurant
245export const Websocket = _autoLoaded.Websocket
246
247// Inject every loaded model onto globalThis so dashboard `<script server>`
389// Inject every model Proxy onto globalThis so dashboard `<script server>`
248390// blocks can reference them as bare names (e.g. `await Order.all()`) without
249391// having to import. The STX engine evaluates server scripts inside an
250392// AsyncFunction wrapper, which inherits from globalThis — assigning here
251// makes the names visible to every page render after orm has loaded.
393// makes the names visible to every page render. The values are Proxies that
394// forward to `_loaded[name]` at access time; until the deferred microtask
395// populates `_loaded`, every method access returns undefined. By the time
396// any stx page render fires (request time), the microtask queue has long
397// drained, so `await Order.all()` resolves transparently.
252398const _allExports: Record<string, any> = {
253 User,
254 Job,
255 FailedJob,
256 ..._autoLoaded,
399 User, Job, FailedJob,
400 Activity, Author, Campaign, CampaignSend, Cart, CartItem, Category,
401 Comment, Coupon, Customer, DeliveryRoute, Deployment, DigitalDelivery,
402 Driver, EmailList, EmailListSubscriber, ErrorModel, GiftCard, LicenseKey,
403 Log, LoyaltyPoint, LoyaltyReward, Manufacturer, Notification, Order,
404 OrderItem, Page, Payment, PaymentMethod, PaymentProduct, PaymentTransaction,
405 Post, PrintDevice, Product, ProductUnit, ProductVariant, Receipt, Release,
406 Request, Review, ShippingMethod, ShippingRate, ShippingZone, SocialPost,
407 Subscriber, SubscriberEmail, Subscription, Tag, TaxRate, Team, Transaction,
408 WaitlistProduct, WaitlistRestaurant, Websocket,
257409}
258410const g = globalThis as Record<string, any>
259411for (const [name, M] of Object.entries(_allExports)) {
storage/framework/core/orm/src/traits/billable.tsmodified+1-2
Changes to storage/framework/core/orm/src/traits/billable.ts
@@ -1,9 +1,8 @@
11import { db as _db } from '@stacksjs/database'
22
3// See note in categorizable.ts — relax db method types for trait helpers.
4const db = _db as any
53
64export function createBillableMethods(_tableName: string) {
5 const db = _db as any
76 return {
87 async createStripeUser(model: any, options: any): Promise<any> {
98 const { manageCustomer } = await import('@stacksjs/payments')
storage/framework/core/orm/src/traits/categorizable.tsmodifiedformatting only+1-1
Changes to storage/framework/core/orm/src/traits/categorizable.ts
@@ -4,9 +4,9 @@ import { db as _db } from '@stacksjs/database'
44// resolution to the concrete invocation here can leave methods marked
55// `T | undefined` under strict null checks. Cast through `any` so the trait
66// helpers can call the runtime-defined methods without a guard at every site.
7const db = _db as any
87
98export function createCategorizableMethods(tableName: string) {
9 const db = _db as any
1010 async function getCategoryIds(id: number): Promise<number[]> {
1111 const categoryLinks = await db
1212 .selectFrom('categorizable_models')
storage/framework/core/orm/src/traits/commentable.tsmodified+1-2
Changes to storage/framework/core/orm/src/traits/commentable.ts
@@ -1,7 +1,5 @@
11import { db as _db, sql } from '@stacksjs/database'
22
3// See note in categorizable.ts — relax db method types for trait helpers.
4const db = _db as any
53
64function assertId(id: unknown, method: string): asserts id is number {
75 if (typeof id !== 'number' || !Number.isFinite(id) || id <= 0) {
@@ -10,6 +8,7 @@ function assertId(id: unknown, method: string): asserts id is number {
108}
119
1210export function createCommentableMethods(tableName: string) {
11 const db = _db as any
1312 return {
1413 async comments(id: number): Promise<any[]> {
1514 assertId(id, 'comments')
storage/framework/core/orm/src/traits/likeable.tsmodified+1-2
Changes to storage/framework/core/orm/src/traits/likeable.ts
@@ -1,9 +1,8 @@
11import { db as _db, sql } from '@stacksjs/database'
22
3// See note in categorizable.ts — relax db method types for trait helpers.
4const db = _db as any
53
64export function createLikeableMethods(tableName: string, options?: { table?: string, foreignKey?: string }) {
5 const db = _db as any
76 const likeTable = options?.table || `${tableName}_likes`
87 const foreignKey = options?.foreignKey || `${tableName.replace(/s$/, '')}_id`
98
storage/framework/core/orm/src/traits/taggable.tsmodified+1-2
Changes to storage/framework/core/orm/src/traits/taggable.ts
@@ -1,7 +1,5 @@
11import { db as _db, sql } from '@stacksjs/database'
22
3// See note in categorizable.ts — relax db method types for trait helpers.
4const db = _db as any
53
64/**
75 * Validate the parent record id passed to a trait method. Negative or
@@ -15,6 +13,7 @@ function assertId(id: unknown, method: string): asserts id is number {
1513}
1614
1715export function createTaggableMethods(tableName: string) {
16 const db = _db as any
1817 return {
1918 async tags(id: number): Promise<any[]> {
2019 assertId(id, 'tags')
storage/framework/core/router/src/route-loader.tsmodified+15-0
Changes to storage/framework/core/router/src/route-loader.ts
@@ -73,6 +73,21 @@ export async function loadRoutes(registry: RouteRegistry): Promise<void> {
7373 * package runs from the workspace or from `node_modules/@stacksjs/router/`.
7474 */
7575async function loadFrameworkRoutes(): Promise<void> {
76 // Projects that don't use the bundled dashboard / commerce / monitoring
77 // surface area can opt out of framework default route registration
78 // entirely by setting `STACKS_SKIP_DEFAULT_ROUTES=1`. Without it, the
79 // bootstrap imports `defaults/routes/dashboard.ts` (and friends), each
80 // of which references actions that pull in models like `Product`,
81 // `Coupon`, `WaitlistRestaurant`, `Error` — and projects that don't
82 // ship those models flood their API boot with
83 // `[Router] Failed to import action ...` lines for every missing one.
84 //
85 // The per-route override pattern (declare the same path first in
86 // `routes/api.ts`) only scales when you want a handful of overrides;
87 // for projects that want none of the bundled domain at all this gate
88 // is the wholesale escape hatch. Pattern matches the existing
89 // `STACKS_DEV_DASHBOARD=1` opt-in on the dev-server side.
90 if (process.env.STACKS_SKIP_DEFAULT_ROUTES === '1') return
7691 try {
7792 const { frameworkPath } = await import('@stacksjs/path')
7893 const bootstrapPath = frameworkPath('defaults/bootstrap.ts')
storage/framework/core/types/src/features.tsadded+56-0
Changes to storage/framework/core/types/src/features.ts
@@ -0,0 +1,56 @@
1/**
2 * **Stacks Feature Bundles**
3 *
4 * Each named feature in this manifest is a framework bundle (a set of
5 * models, routes, actions, and views) that gets registered at boot only
6 * when its flag is `true`. Apps that don't need Commerce/CMS/Monitoring
7 * leave those flags `false` (or omitted) and pay nothing for them at boot.
8 *
9 * Add/remove via `./buddy <feature>:install` / `./buddy <feature>:uninstall`
10 * rather than editing the config file directly. The install commands flip
11 * the flag, run any feature-specific migrations, and copy starter files.
12 *
13 * Inspired by Laravel's `php artisan passport:install` / `horizon:install`
14 * pattern — features are inert dead code on disk until you install them.
15 *
16 * Each flag accepts either:
17 * - `true` / `false` — simple on/off
18 * - `{ enabled: true, env: ['production'] }` — gate to specific deploy
19 * targets (compared against `config.app.env`)
20 */
21export interface FeaturesConfig {
22 /** Always activated. Defines the User model and base auth tables. */
23 core?: FeatureFlag
24 /** Token + password-reset + email-verification flows. */
25 auth?: FeatureFlag
26 /** `/api/email/subscribe`, `/api/contact`, basic SEO routes. */
27 marketing?: FeatureFlag
28 /** Post / Page / Author / Comment / Tag / Category + edit dashboard. */
29 cms?: FeatureFlag
30 /** Order, Cart, Product, Customer, Coupon, GiftCard, Receipt, Shipping. */
31 commerce?: FeatureFlag
32 /** Admin SPA shell + Activity / Log / Request / Notification dashboards. */
33 dashboard?: FeatureFlag
34 /** Error monitoring views/actions (subset of dashboard, usable alone). */
35 monitoring?: FeatureFlag
36 /** WebSocket broadcasting + Websocket model + realtime stats actions. */
37 realtime?: FeatureFlag
38 /** Job queue runtime + FailedJob model + Job dashboard pages. */
39 queue?: FeatureFlag
40
41 /**
42 * Apps can also declare custom feature flags here and check them with
43 * `feature('my-flag')` from `@stacksjs/config`.
44 */
45 [name: string]: FeatureFlag | undefined
46}
47
48export type FeatureFlag = boolean | { enabled?: boolean, env?: string[] }
49
50/**
51 * The full set of framework-bundled features. Install commands type-check
52 * against this union so a typo like `commerse` fails at compile time.
53 */
54export type StacksFeature =
55 | 'core' | 'auth' | 'marketing' | 'cms' | 'commerce'
56 | 'dashboard' | 'monitoring' | 'realtime' | 'queue'
storage/framework/core/types/src/index.tsmodified+1-0
Changes to storage/framework/core/types/src/index.ts
@@ -29,6 +29,7 @@ export * from './env'
2929export * from './errors'
3030export * from './events'
3131export * from './exit-code'
32export * from './features'
3233export * from './file-systems'
3334export * from './git'
3435export * from './hashing'
storage/framework/defaults/bootstrap.tsmodified+40-6
Changes to storage/framework/defaults/bootstrap.ts
@@ -21,12 +21,46 @@
2121 * @see storage/framework/core/router/src/route-loader.ts:loadFrameworkRoutes
2222 */
2323
24import { feature } from '@stacksjs/config'
2425import { frameworkPath } from '@stacksjs/path'
2526import { route } from '@stacksjs/router'
27import MaintenanceMiddleware from './app/Middleware/Maintenance'
2628
27// Dashboard, auth, password, email, etc. Currently lives as a single
28// 687-line routes file under defaults/routes/dashboard.ts. As each
29// subdomain grows it can split into smaller files (auth.ts, email.ts,
30// commerce.ts) and each gets its own register() line — or moves into a
31// dedicated workspace package whose index.ts self-registers.
32await route.register(frameworkPath('defaults/routes/dashboard.ts'))
29// Global maintenance / coming-soon gate. Registered first so the
30// `buddy down` / `buddy coming-soon` (and their env-var equivalents)
31// state files intercept every request before any other middleware or
32// route runs.
33//
34// The middleware is a thin wrapper around `maintenanceGate()` in
35// @stacksjs/server, which is also called from the dev server's
36// `onRequest` hook — so dev and prod use one implementation. The gate
37// itself reads `activeSiteModePayload()` so it covers both maintenance
38// and coming-soon modes (plus `APP_MAINTENANCE` / `APP_COMING_SOON`
39// env overrides) without the bootstrap needing to know the details.
40route.use(MaintenanceMiddleware.handle.bind(MaintenanceMiddleware) as any)
41
42// Feature-gated route registration. The dashboard.ts file currently bundles
43// ~687 lines covering auth, password reset, email subscribe, storefront
44// cart/checkout, reviews, sitemap, AI, voice, and the admin dashboard's
45// REST surface. Until that file is split per-feature (auth.ts, marketing.ts,
46// commerce.ts, monitoring.ts), the whole thing loads when `dashboard` is
47// activated and stays inert otherwise.
48//
49// Apps that need only a slice — e.g. a marketing site that wants
50// `/api/email/subscribe` and `/api/contact` but not the rest — can either
51// 1. Activate `dashboard` and live with the over-broad register; the
52// action handlers for routes you don't hit never fire, and their
53// models stay un-loaded as long as the corresponding feature flag
54// (`commerce`, `cms`, `monitoring`) is off, so there's no hidden
55// cost beyond the bun-router route-table entries.
56// 2. Define the routes they want directly in `routes/api.ts` —
57// first-registration-wins means the user version takes priority.
58//
59// Once the per-feature route split lands, each `if (feature('X'))` block
60// below registers just the X-specific routes file.
61if (feature('dashboard')) {
62 await route.register(frameworkPath('defaults/routes/dashboard.ts'))
63 // JSON endpoints for the dev dashboard UI. Kept separate from the view
64 // routes above so the data layer is one obvious file to grep.
65 await route.register(frameworkPath('defaults/routes/dashboard-api.ts'))
66}