also looking at this
umbrella: pet-store storefront fixes (rolling)
#3
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.
| @@ -0,0 +1,22 @@ | ||
| 1 | import 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 | */ | |
| 12 | export 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 | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { AsyncLocalStorage } from 'node:async_hooks' |
| 2 | import { existsSync } from 'node:fs' | |
| 2 | 3 | import { config } from '@stacksjs/config' |
| 3 | 4 | import { projectPath } from '@stacksjs/path' |
| 4 | 5 | |
| @@ -185,10 +186,25 @@ async function startDefaultServer() { | ||
| 185 | 186 | onRequest: async (req: Request) => { |
| 186 | 187 | const url = new URL(req.url) |
| 187 | 188 | |
| 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)) | |
| 192 | 208 | return proxyToApi(req, apiBase) |
| 193 | 209 | |
| 194 | 210 | // Stash cookies + url so server-script blocks rendering this |
| @@ -205,13 +221,15 @@ async function startDefaultServer() { | ||
| 205 | 221 | } |
| 206 | 222 | |
| 207 | 223 | async 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}/`. | |
| 208 | 230 | 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 | |
| 215 | 233 | } |
| 216 | 234 | return null |
| 217 | 235 | } |
| @@ -0,0 +1,135 @@ | ||
| 1 | import type { CLI } from '@stacksjs/types' | |
| 2 | import process from 'node:process' | |
| 3 | import { projectPath } from '@stacksjs/path' | |
| 4 | import { 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 | ||
| 24 | const FEATURE_NAMES = [ | |
| 25 | 'dashboard', | |
| 26 | 'commerce', | |
| 27 | 'cms', | |
| 28 | 'marketing', | |
| 29 | 'monitoring', | |
| 30 | 'realtime', | |
| 31 | 'queue', | |
| 32 | ] as const | |
| 33 | ||
| 34 | type FeatureName = (typeof FEATURE_NAMES)[number] | |
| 35 | ||
| 36 | const 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 | */ | |
| 51 | async 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 | ||
| 60 | export 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 | ||
| 92 | function 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 | ||
| 132 | export function features(buddy: CLI): void { | |
| 133 | for (const feature of FEATURE_NAMES) | |
| 134 | registerInstallPair(buddy, feature) | |
| 135 | } | |
| @@ -33,6 +33,21 @@ const commandRegistry: Record<string, CommandLoader> = { | ||
| 33 | 33 | 'domains': { path: './commands/domains.ts', exportName: 'domains' }, |
| 34 | 34 | 'email': { path: './commands/email.ts', exportName: 'email' }, |
| 35 | 35 | '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' }, | |
| 36 | 51 | 'fresh': { path: './commands/fresh.ts', exportName: 'fresh' }, |
| 37 | 52 | 'generate': { path: './commands/generate.ts', exportName: 'generate' }, |
| 38 | 53 | 'http': { path: './commands/http.ts', exportName: 'http' }, |
| @@ -76,7 +76,13 @@ async function loadUserlandModel(modelName: string, subdirs: string[] = ['']): P | ||
| 76 | 76 | return (await import(userPath)).default |
| 77 | 77 | } |
| 78 | 78 | } |
| 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 | } | |
| 80 | 86 | // 2) Framework default at storage/framework/defaults/app/Models/<Name>.ts |
| 81 | 87 | // Defaults are organised in subdirectories (commerce/, Content/, |
| 82 | 88 | // realtime/) so each model is searched in the relevant subdir first |
| @@ -89,171 +95,317 @@ async function loadUserlandModel(modelName: string, subdirs: string[] = ['']): P | ||
| 89 | 95 | return (await import(defaultPath)).default |
| 90 | 96 | } |
| 91 | 97 | } |
| 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 | } | |
| 93 | 103 | } |
| 94 | 104 | return null |
| 95 | 105 | } |
| 96 | 106 | |
| 97 | export 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 | ||
| 133 | const _loaded: Record<string, any> = {} | |
| 98 | 134 | |
| 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. | |
| 106 | export const Job = await loadUserlandModel('Job') | |
| 107 | export const FailedJob = await loadUserlandModel('FailedJob') | |
| 135 | let _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 | */ | |
| 143 | export 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 | */ | |
| 151 | function 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 | ||
| 190 | export 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. | |
| 205 | export const Job: any = lazyModel('Job') | |
| 206 | export const FailedJob: any = lazyModel('FailedJob') | |
| 108 | 207 | |
| 109 | 208 | // --------------------------------------------------------------------------- |
| 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 | // | |
| 112 | 218 | // The root namespace is searched first (so a flattened user override wins), |
| 113 | 219 | // then the subdir for the framework default. Loaded sequentially so the |
| 114 | 220 | // schema-TDZ cycle the original `User`-only export was guarding against |
| 115 | 221 | // stays neutralised. |
| 116 | 222 | // --------------------------------------------------------------------------- |
| 117 | const 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', ['']], | |
| 223 | const 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'], | |
| 152 | 262 | |
| 153 | 263 | // 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'], | |
| 183 | 293 | ] |
| 184 | 294 | |
| 185 | const _autoLoaded: Record<string, any> = {} | |
| 186 | for (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. | |
| 300 | queueMicrotask(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 | ||
| 334 | export const Activity: any = lazyModel('Activity') | |
| 335 | export const Author: any = lazyModel('Author') | |
| 336 | export const Campaign: any = lazyModel('Campaign') | |
| 337 | export const Cart: any = lazyModel('Cart') | |
| 338 | export const CartItem: any = lazyModel('CartItem') | |
| 339 | export const Category: any = lazyModel('Category') | |
| 340 | export const Comment: any = lazyModel('Comment') | |
| 341 | export const Coupon: any = lazyModel('Coupon') | |
| 342 | export const Customer: any = lazyModel('Customer') | |
| 343 | export const DeliveryRoute: any = lazyModel('DeliveryRoute') | |
| 344 | export const Deployment: any = lazyModel('Deployment') | |
| 345 | export const DigitalDelivery: any = lazyModel('DigitalDelivery') | |
| 346 | export const Driver: any = lazyModel('Driver') | |
| 347 | export const CampaignSend: any = lazyModel('CampaignSend') | |
| 348 | export const EmailList: any = lazyModel('EmailList') | |
| 349 | export const EmailListSubscriber: any = lazyModel('EmailListSubscriber') | |
| 350 | export const ErrorModel: any = lazyModel('Error') | |
| 351 | export const GiftCard: any = lazyModel('GiftCard') | |
| 352 | export const LicenseKey: any = lazyModel('LicenseKey') | |
| 353 | export const Log: any = lazyModel('Log') | |
| 354 | export const LoyaltyPoint: any = lazyModel('LoyaltyPoint') | |
| 355 | export const LoyaltyReward: any = lazyModel('LoyaltyReward') | |
| 356 | export const Manufacturer: any = lazyModel('Manufacturer') | |
| 357 | export const Notification: any = lazyModel('Notification') | |
| 358 | export const Order: any = lazyModel('Order') | |
| 359 | export const OrderItem: any = lazyModel('OrderItem') | |
| 360 | export const Page: any = lazyModel('Page') | |
| 361 | export const Payment: any = lazyModel('Payment') | |
| 362 | export const PaymentMethod: any = lazyModel('PaymentMethod') | |
| 363 | export const PaymentProduct: any = lazyModel('PaymentProduct') | |
| 364 | export const PaymentTransaction: any = lazyModel('PaymentTransaction') | |
| 365 | export const Post: any = lazyModel('Post') | |
| 366 | export const PrintDevice: any = lazyModel('PrintDevice') | |
| 367 | export const Product: any = lazyModel('Product') | |
| 368 | export const ProductUnit: any = lazyModel('ProductUnit') | |
| 369 | export const ProductVariant: any = lazyModel('ProductVariant') | |
| 370 | export const Receipt: any = lazyModel('Receipt') | |
| 371 | export const Release: any = lazyModel('Release') | |
| 372 | export const Request: any = lazyModel('Request') | |
| 373 | export const Review: any = lazyModel('Review') | |
| 374 | export const ShippingMethod: any = lazyModel('ShippingMethod') | |
| 375 | export const ShippingRate: any = lazyModel('ShippingRate') | |
| 376 | export const ShippingZone: any = lazyModel('ShippingZone') | |
| 377 | export const SocialPost: any = lazyModel('SocialPost') | |
| 378 | export const Subscriber: any = lazyModel('Subscriber') | |
| 379 | export const SubscriberEmail: any = lazyModel('SubscriberEmail') | |
| 380 | export const Subscription: any = lazyModel('Subscription') | |
| 381 | export const Tag: any = lazyModel('Tag') | |
| 382 | export const TaxRate: any = lazyModel('TaxRate') | |
| 383 | export const Team: any = lazyModel('Team') | |
| 384 | export const Transaction: any = lazyModel('Transaction') | |
| 385 | export const WaitlistProduct: any = lazyModel('WaitlistProduct') | |
| 386 | export const WaitlistRestaurant: any = lazyModel('WaitlistRestaurant') | |
| 387 | export const Websocket: any = lazyModel('Websocket') | |
| 191 | 388 | |
| 192 | export const Activity = _autoLoaded.Activity | |
| 193 | export const Author = _autoLoaded.Author | |
| 194 | export const Campaign = _autoLoaded.Campaign | |
| 195 | export const Cart = _autoLoaded.Cart | |
| 196 | export const CartItem = _autoLoaded.CartItem | |
| 197 | export const Category = _autoLoaded.Category | |
| 198 | export const Comment = _autoLoaded.Comment | |
| 199 | export const Coupon = _autoLoaded.Coupon | |
| 200 | export const Customer = _autoLoaded.Customer | |
| 201 | export const DeliveryRoute = _autoLoaded.DeliveryRoute | |
| 202 | export const Deployment = _autoLoaded.Deployment | |
| 203 | export const DigitalDelivery = _autoLoaded.DigitalDelivery | |
| 204 | export const Driver = _autoLoaded.Driver | |
| 205 | export const CampaignSend = _autoLoaded.CampaignSend | |
| 206 | export const EmailList = _autoLoaded.EmailList | |
| 207 | export const EmailListSubscriber = _autoLoaded.EmailListSubscriber | |
| 208 | export const ErrorModel = _autoLoaded.Error | |
| 209 | export const GiftCard = _autoLoaded.GiftCard | |
| 210 | export const LicenseKey = _autoLoaded.LicenseKey | |
| 211 | export const Log = _autoLoaded.Log | |
| 212 | export const LoyaltyPoint = _autoLoaded.LoyaltyPoint | |
| 213 | export const LoyaltyReward = _autoLoaded.LoyaltyReward | |
| 214 | export const Manufacturer = _autoLoaded.Manufacturer | |
| 215 | export const Notification = _autoLoaded.Notification | |
| 216 | export const Order = _autoLoaded.Order | |
| 217 | export const OrderItem = _autoLoaded.OrderItem | |
| 218 | export const Page = _autoLoaded.Page | |
| 219 | export const Payment = _autoLoaded.Payment | |
| 220 | export const PaymentMethod = _autoLoaded.PaymentMethod | |
| 221 | export const PaymentProduct = _autoLoaded.PaymentProduct | |
| 222 | export const PaymentTransaction = _autoLoaded.PaymentTransaction | |
| 223 | export const Post = _autoLoaded.Post | |
| 224 | export const PrintDevice = _autoLoaded.PrintDevice | |
| 225 | export const Product = _autoLoaded.Product | |
| 226 | export const ProductUnit = _autoLoaded.ProductUnit | |
| 227 | export const ProductVariant = _autoLoaded.ProductVariant | |
| 228 | export const Receipt = _autoLoaded.Receipt | |
| 229 | export const Release = _autoLoaded.Release | |
| 230 | export const Request = _autoLoaded.Request | |
| 231 | export const Review = _autoLoaded.Review | |
| 232 | export const ShippingMethod = _autoLoaded.ShippingMethod | |
| 233 | export const ShippingRate = _autoLoaded.ShippingRate | |
| 234 | export const ShippingZone = _autoLoaded.ShippingZone | |
| 235 | export const SocialPost = _autoLoaded.SocialPost | |
| 236 | export const Subscriber = _autoLoaded.Subscriber | |
| 237 | export const SubscriberEmail = _autoLoaded.SubscriberEmail | |
| 238 | export const Subscription = _autoLoaded.Subscription | |
| 239 | export const Tag = _autoLoaded.Tag | |
| 240 | export const TaxRate = _autoLoaded.TaxRate | |
| 241 | export const Team = _autoLoaded.Team | |
| 242 | export const Transaction = _autoLoaded.Transaction | |
| 243 | export const WaitlistProduct = _autoLoaded.WaitlistProduct | |
| 244 | export const WaitlistRestaurant = _autoLoaded.WaitlistRestaurant | |
| 245 | export 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>` | |
| 248 | 390 | // blocks can reference them as bare names (e.g. `await Order.all()`) without |
| 249 | 391 | // having to import. The STX engine evaluates server scripts inside an |
| 250 | 392 | // 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. | |
| 252 | 398 | const _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, | |
| 257 | 409 | } |
| 258 | 410 | const g = globalThis as Record<string, any> |
| 259 | 411 | for (const [name, M] of Object.entries(_allExports)) { |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | import { db as _db } from '@stacksjs/database' |
| 2 | 2 | |
| 3 | // See note in categorizable.ts — relax db method types for trait helpers. | |
| 4 | const db = _db as any | |
| 5 | 3 | |
| 6 | 4 | export function createBillableMethods(_tableName: string) { |
| 5 | const db = _db as any | |
| 7 | 6 | return { |
| 8 | 7 | async createStripeUser(model: any, options: any): Promise<any> { |
| 9 | 8 | const { manageCustomer } = await import('@stacksjs/payments') |
| @@ -4,9 +4,9 @@ import { db as _db } from '@stacksjs/database' | ||
| 4 | 4 | // resolution to the concrete invocation here can leave methods marked |
| 5 | 5 | // `T | undefined` under strict null checks. Cast through `any` so the trait |
| 6 | 6 | // helpers can call the runtime-defined methods without a guard at every site. |
| 7 | const db = _db as any | |
| 8 | 7 | |
| 9 | 8 | export function createCategorizableMethods(tableName: string) { |
| 9 | const db = _db as any | |
| 10 | 10 | async function getCategoryIds(id: number): Promise<number[]> { |
| 11 | 11 | const categoryLinks = await db |
| 12 | 12 | .selectFrom('categorizable_models') |
| @@ -1,7 +1,5 @@ | ||
| 1 | 1 | import { db as _db, sql } from '@stacksjs/database' |
| 2 | 2 | |
| 3 | // See note in categorizable.ts — relax db method types for trait helpers. | |
| 4 | const db = _db as any | |
| 5 | 3 | |
| 6 | 4 | function assertId(id: unknown, method: string): asserts id is number { |
| 7 | 5 | if (typeof id !== 'number' || !Number.isFinite(id) || id <= 0) { |
| @@ -10,6 +8,7 @@ function assertId(id: unknown, method: string): asserts id is number { | ||
| 10 | 8 | } |
| 11 | 9 | |
| 12 | 10 | export function createCommentableMethods(tableName: string) { |
| 11 | const db = _db as any | |
| 13 | 12 | return { |
| 14 | 13 | async comments(id: number): Promise<any[]> { |
| 15 | 14 | assertId(id, 'comments') |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | import { db as _db, sql } from '@stacksjs/database' |
| 2 | 2 | |
| 3 | // See note in categorizable.ts — relax db method types for trait helpers. | |
| 4 | const db = _db as any | |
| 5 | 3 | |
| 6 | 4 | export function createLikeableMethods(tableName: string, options?: { table?: string, foreignKey?: string }) { |
| 5 | const db = _db as any | |
| 7 | 6 | const likeTable = options?.table || `${tableName}_likes` |
| 8 | 7 | const foreignKey = options?.foreignKey || `${tableName.replace(/s$/, '')}_id` |
| 9 | 8 | |
| @@ -1,7 +1,5 @@ | ||
| 1 | 1 | import { db as _db, sql } from '@stacksjs/database' |
| 2 | 2 | |
| 3 | // See note in categorizable.ts — relax db method types for trait helpers. | |
| 4 | const db = _db as any | |
| 5 | 3 | |
| 6 | 4 | /** |
| 7 | 5 | * 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 { | ||
| 15 | 13 | } |
| 16 | 14 | |
| 17 | 15 | export function createTaggableMethods(tableName: string) { |
| 16 | const db = _db as any | |
| 18 | 17 | return { |
| 19 | 18 | async tags(id: number): Promise<any[]> { |
| 20 | 19 | assertId(id, 'tags') |
| @@ -73,6 +73,21 @@ export async function loadRoutes(registry: RouteRegistry): Promise<void> { | ||
| 73 | 73 | * package runs from the workspace or from `node_modules/@stacksjs/router/`. |
| 74 | 74 | */ |
| 75 | 75 | async 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 | |
| 76 | 91 | try { |
| 77 | 92 | const { frameworkPath } = await import('@stacksjs/path') |
| 78 | 93 | const bootstrapPath = frameworkPath('defaults/bootstrap.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 | */ | |
| 21 | export 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 | ||
| 48 | export 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 | */ | |
| 54 | export type StacksFeature = | |
| 55 | | 'core' | 'auth' | 'marketing' | 'cms' | 'commerce' | |
| 56 | | 'dashboard' | 'monitoring' | 'realtime' | 'queue' | |
| @@ -21,12 +21,46 @@ | ||
| 21 | 21 | * @see storage/framework/core/router/src/route-loader.ts:loadFrameworkRoutes |
| 22 | 22 | */ |
| 23 | 23 | |
| 24 | import { feature } from '@stacksjs/config' | |
| 24 | 25 | import { frameworkPath } from '@stacksjs/path' |
| 25 | 26 | import { route } from '@stacksjs/router' |
| 27 | import MaintenanceMiddleware from './app/Middleware/Maintenance' | |
| 26 | 28 | |
| 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. | |
| 32 | await 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. | |
| 40 | route.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. | |
| 61 | if (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 | } | |