@stacksjs/defaults 0.72.97 → 0.72.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/ai/skills/stacks-auth/SKILL.md +35 -5
  2. package/ai/skills/stacks-config/SKILL.md +11 -1
  3. package/ai/skills/stacks-desktop/SKILL.md +46 -0
  4. package/ai/skills/stacks-env/SKILL.md +26 -0
  5. package/ai/skills/stacks-events/SKILL.md +76 -60
  6. package/ai/skills/stacks-jobs/SKILL.md +3 -0
  7. package/ai/skills/stacks-listeners/SKILL.md +39 -9
  8. package/ai/skills/stacks-middleware/SKILL.md +50 -12
  9. package/ai/skills/stacks-queue/SKILL.md +5 -0
  10. package/ai/skills/stacks-router/SKILL.md +17 -7
  11. package/ai/skills/stacks-scheduler/SKILL.md +1 -1
  12. package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +1 -1
  13. package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +1 -1
  14. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +1 -1
  15. package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +1 -1
  16. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -1
  17. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -1
  18. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +1 -1
  19. package/app/Actions/Dashboard/Content/content-input.ts +2 -2
  20. package/app/Actions/Dashboard/Content/post-input.ts +4 -4
  21. package/app/Actions/Dashboard/Email/inbox-request.ts +1 -1
  22. package/app/Actions/Dashboard/Email/mail-preference.ts +1 -1
  23. package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +1 -1
  24. package/app/Actions/Dashboard/Marketing/CampaignStoreAction.ts +1 -1
  25. package/app/Actions/Dashboard/Marketing/CampaignUpdateAction.ts +1 -1
  26. package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +2 -2
  27. package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +2 -2
  28. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +5 -5
  29. package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +2 -2
  30. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
  31. package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +2 -2
  32. package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +2 -2
  33. package/app/Events.ts +9 -4
  34. package/app/Gates.ts +83 -85
  35. package/app/Listener.ts +34 -81
  36. package/app/Middleware.ts +22 -7
  37. package/ide/vscode/package.json +1 -1
  38. package/package.json +2 -2
  39. package/project/storage/framework/server/tsconfig.docker.json +1 -2
  40. package/resources/components/Buttons/Counter.stx +30 -21
  41. package/resources/components/Dashboard/Analytics/WebAnalyticsDashboard.stx +1 -3
  42. package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +1 -1
  43. package/resources/components/Dashboard/Auth/LoginDashboard.stx +2 -1
  44. package/resources/components/Dashboard/Auth/RegisterDashboard.stx +2 -1
  45. package/resources/components/Dashboard/Auth/ResetPasswordDashboard.stx +1 -1
  46. package/resources/components/Dashboard/Ci/CiDashboard.stx +9 -9
  47. package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +7 -7
  48. package/resources/components/Dashboard/Commerce/CommerceOverviewCharts.stx +2 -1
  49. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
  50. package/resources/components/Dashboard/Content/BlogDashboard.stx +37 -15
  51. package/resources/components/Dashboard/Content/ContentDashboard.stx +3 -26
  52. package/resources/components/Dashboard/Email/EmailActivityDashboard.stx +2 -15
  53. package/resources/components/Dashboard/Environment/EnvironmentDashboard.stx +9 -1
  54. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +15 -13
  55. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +10 -8
  56. package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +10 -10
  57. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +3 -3
  58. package/resources/components/Dashboard/Modals/Popups/Alert.stx +4 -1
  59. package/resources/components/Dashboard/Navbar.stx +1 -1
  60. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +11 -6
  61. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +2 -2
  62. package/resources/components/Dashboard/Transaction/index.stx +6 -6
  63. package/resources/components/Dashboard/UI/Avatar.stx +2 -2
  64. package/resources/components/Dashboard/UI/DropdownItem.stx +4 -4
  65. package/resources/components/Dashboard/UI/WindowControls.stx +17 -2
  66. package/resources/components/Docs/Demo/ComboboxDemo.stx +8 -3
  67. package/resources/components/Docs/Demo/DropdownDemo.stx +3 -3
  68. package/resources/components/Docs/Demo/StepperDemo.stx +1 -1
  69. package/resources/components/Forum/ForumLayout.stx +1 -1
  70. package/resources/components/Forum/ForumReplyForm.stx +1 -1
  71. package/resources/components/Marketing/ComingSoon.stx +5 -5
  72. package/resources/components/Storefront/CartDrawer.stx +44 -22
  73. package/resources/components/Storefront/ProductGallery.stx +3 -2
  74. package/resources/components/Storefront/QuantitySelector.stx +12 -7
  75. package/resources/functions/storefront/cart-cookie.ts +58 -0
  76. package/resources/layouts/storefront.stx +3 -2
  77. package/resources/views/cart.stx +19 -3
  78. package/resources/views/checkout/contact.stx +14 -3
  79. package/resources/views/checkout/payment.stx +14 -3
  80. package/resources/views/checkout/shipping.stx +14 -3
  81. package/resources/views/cms/blocks/form.stx +16 -11
  82. package/resources/views/cms/page.stx +28 -0
  83. package/resources/views/coming-soon.stx +0 -6
  84. package/resources/views/orders/[id].stx +19 -3
  85. package/views/dashboard/composables/index.ts +1 -1
  86. package/views/dashboard/composables/useChart.ts +26 -14
  87. package/views/dashboard/layouts/default.stx +42 -25
@@ -363,16 +363,44 @@ await authUser.authorize('edit-post', post) // throws if denied
363
363
 
364
364
  ## Middleware Aliases (app/Middleware.ts)
365
365
 
366
- Available middleware names: `maintenance`, `auth`, `guest`, `api`, `team`, `logger`, `abilities`, `can`, `throttle`, `local`, `development`, `staging`, `production`, `env.local`, `env.development`, `env.staging`, `env.production`, `role`, `permission`, `verified` (EnsureEmailIsVerified)
366
+ Auth-relevant aliases: `auth`, `guest`, `verified` (EnsureEmailIsVerified),
367
+ `abilities`, `can`, `role`, `permission`, `team`, `signed`, `throttle`. The
368
+ environment aliases are `env`, `env:local`, `env:development` / `env:dev`,
369
+ `env:staging`, `env:production` / `env:prod` — with a COLON, not a dot; an
370
+ earlier version of this list wrote `env.local` and those never existed. See
371
+ `stacks-middleware` for the full set and for the `!alias` and `alias:params`
372
+ forms.
367
373
 
368
- ## Application Gates Example (app/Gates.ts)
374
+ ## Application Gates (app/Gates.ts)
369
375
 
370
376
  ```typescript
371
- Gate.define('access-admin', (user) => user?.email?.endsWith('@stacksjs.org') ?? false)
372
- Gate.define('edit-settings', (user) => !!user)
373
- Gate.define('view-dashboard', (user) => !!user)
377
+ import { defineGates } from '@stacksjs/auth'
378
+
379
+ export default defineGates({
380
+ gates: {
381
+ 'access-admin': user => user?.email?.endsWith('@stacksjs.org') ?? false,
382
+ 'edit-settings': user => !!user,
383
+ 'view-dashboard': user => !!user,
384
+ },
385
+ policies: {
386
+ Post: 'PostPolicy',
387
+ },
388
+ })
374
389
  ```
375
390
 
391
+ Registered at boot by `initializeAuthorization()`, from
392
+ `injectGlobalAutoImports()` — the one place every entry point comes through, so
393
+ HTTP, `buddy seed`, a scheduled job and a console command all get the same
394
+ gates.
395
+
396
+ Both halves of `policies` are checked: the key names a model the ORM exposes,
397
+ the value a policy file under `app/Policies/` or the framework defaults. An
398
+ explicit mapping WINS over the `<Model>Policy` naming convention, which is the
399
+ reason to write one.
400
+
401
+ `Gate.define(...)` still works for a gate registered at runtime; `defineGates`
402
+ is the declarative form and the one the ability-name completions come from.
403
+
376
404
  ## Default API Routes
377
405
 
378
406
  - `POST /login` → LoginAction (validates email + password)
@@ -409,6 +437,8 @@ traits: {
409
437
  - RBAC has an internal cache (`userRoles`, `userPermissions`, `rolePermissions`) — call `Rbac.flushCache()` after direct DB changes
410
438
  - `syncRoles()` and `syncPermissions()` are guard-scoped replacements: they preserve assignments belonging to other guards
411
439
  - Gate `before` callbacks can short-circuit — return `true` to allow, `null` to continue checking
440
+ - An ability with no gate and no policy method **denies**. That is the right default, and it means a gate that was never registered is indistinguishable from one that says no — which is how `initializeAuthorization()` went unnoticed while nothing called it
441
+ - `allows()` and friends take `Ability`, which is open (`GateName | PolicyAbility | (string & {})`). A `/can/:ability` route passes an ability straight through, so narrowing it would reject correct code; the union is for completions
412
442
  - `withRbac()` and `withAuthorization()` return new objects with methods mixed in
413
443
  - The `RbacStore` interface must be implemented and set via `Rbac.setStore()` for RBAC to work
414
444
  - Password reset tokens expire after 60 minutes by default
@@ -44,7 +44,17 @@ export default defineApp({
44
44
  }) satisfies AppConfig
45
45
  ```
46
46
 
47
- All builders: `defineApp`, `defineCache`, `defineCdn`, `defineChat`, `defineCli`, `defineDatabase`, `defineDependencies`, `defineDns`, `defineEmailConfig`, `defineEmail`, `defineGit`, `defineHashing`, `defineLibrary`, `defineNotification`, `definePayment`, `defineQueue`, `defineSearchEngine`, `defineSecurity`, `defineServices`, `defineSms`, `defineFilesystems`, `defineUi`, `defineModel`, `defineEvents`
47
+ All builders: `defineApp`, `defineCache`, `defineCdn`, `defineChat`, `defineCli`, `defineDatabase`, `defineDependencies`, `defineDns`, `defineEmailConfig`, `defineEmail`, `defineGit`, `defineHashing`, `defineLibrary`, `defineNotification`, `definePayment`, `defineQueue`, `defineSearchEngine`, `defineSecurity`, `defineServices`, `defineSms`, `defineFilesystems`, `defineUi`, `defineEvents`
48
+
49
+ `defineModel` is NOT among them: it comes from `@stacksjs/orm`, and it builds a
50
+ model rather than returning a config object. `@stacksjs/config` used to export a
51
+ second one typed `(config: Model) => Model`, which widened every literal a model
52
+ declared - its table name, its attribute names - so a model that imported the
53
+ wrong one silently lost the typing the ORM version exists to provide.
54
+
55
+ The app-level registries have their own helpers, in the packages that own what
56
+ they name: `defineEvents` and `defineListener` from `@stacksjs/events`,
57
+ `defineMiddleware` from `@stacksjs/router`, `defineGates` from `@stacksjs/auth`.
48
58
 
49
59
  ## Helper Functions
50
60
  - `determineAppEnv(): 'dev' | 'stage' | 'prod' | string`
@@ -74,6 +74,52 @@ interface Desktop {
74
74
  }
75
75
  ```
76
76
 
77
+ ## Local-first apps: owning the launcher
78
+
79
+ The launcher Stacks compiles opens a Craft window on the URL in `desktop.json`,
80
+ which is what a hosted Stacks application wants. An app whose subject is the
81
+ machine it runs on — a disk cleaner, a log viewer, a device tool — has no such
82
+ URL: it starts something locally and opens a window on that, on a port it does
83
+ not know until launch.
84
+
85
+ Write `app/Desktop/launcher.ts` and `build:desktop` compiles that instead, the
86
+ same way anything under `app/` overrides its framework default. `DESKTOP_URL`
87
+ and `APP_URL` then become optional, because the launcher decides what to open.
88
+
89
+ ```ts
90
+ // app/Desktop/launcher.ts — compiled to Contents/MacOS/<AppName>
91
+ import { dirname, join } from 'node:path'
92
+
93
+ const macos = dirname(process.execPath) // siblings live here
94
+ const server = Bun.spawn([join(macos, 'my-agent')], { stdout: 'pipe' })
95
+ const port = await readPortFrom(server.stdout) // the agent picks a free one
96
+
97
+ const craft = Bun.spawn([
98
+ join(macos, 'craft-runtime'),
99
+ `http://127.0.0.1:${port}`,
100
+ '--title', 'My App',
101
+ ], { stdout: 'inherit' })
102
+
103
+ process.exit(await craft.exited)
104
+ ```
105
+
106
+ Two things follow from declaring your own launcher:
107
+
108
+ - **Every file `build:desktop` leaves in `storage/framework/desktop-dist` is
109
+ copied into `Contents/MacOS`.** Compile the sibling binaries your launcher
110
+ spawns into that directory and they ship with it.
111
+ - **`app/Desktop/Resources/` is copied into `Contents/Resources`.** A
112
+ prerendered UI, a schema, seed data — a local-first app has a payload, and
113
+ this is where it goes.
114
+
115
+ `build:dmg` also narrows App Transport Security for these bundles: an exception
116
+ for `127.0.0.1` rather than `NSAllowsArbitraryLoads`, which would additionally
117
+ permit every unencrypted host on the internet.
118
+
119
+ Application data belongs in `~/Library/Application Support/<AppName>`, never
120
+ inside the bundle — `/Applications` is not writable by the user, and the bundle
121
+ is replaced wholesale on update.
122
+
77
123
  ## CLI Commands
78
124
 
79
125
  ```bash
@@ -57,6 +57,30 @@ The `env` proxy auto-coerces: `'true'` → `true`, `'123'` → `123`, etc.
57
57
  ## StacksEnv Type (100+ typed variables)
58
58
  App, Ports, API, Database, AWS, Mail, Services (Stripe, Meilisearch), Frontend, Realtime, Redis, Pusher, Auth, Storage, Queue, plus `[key: string]` catch-all.
59
59
 
60
+ ## Adding your own variables
61
+
62
+ Declare them in `config/env.ts`. That is the whole step:
63
+
64
+ ```typescript
65
+ // config/env.ts
66
+ export default defineEnv({
67
+ STRIPE_WEBHOOK_SECRET: { validation: schema.string(), default: '' },
68
+ BILLING_RETRIES: { validation: schema.number(), default: 3 },
69
+ DEPLOY_TARGET: { validation: schema.enum(['staging', 'production']), default: 'staging' },
70
+ })
71
+ ```
72
+
73
+ `env.BILLING_RETRIES` is a `number` and `env.DEPLOY_TARGET` is
74
+ `'staging' | 'production'` everywhere, from the validator - nothing is
75
+ generated, so a variable that exists only in your deploy secrets is typed
76
+ exactly like one in your local `.env`.
77
+
78
+ `storage/framework/types/env.d.ts` reads the schema and extends `StacksEnv`.
79
+ Do NOT write a `declare module '@stacksjs/env'` block of your own: the
80
+ framework already has one, and a second augmentation listing your keys again
81
+ typechecks while letting a key be typed without being validated or defaulted -
82
+ which is the failure `config/env.ts` exists to prevent.
83
+
60
84
  ## Runtime Detection
61
85
 
62
86
  ```typescript
@@ -199,3 +223,5 @@ calls or raw client-side `fetch`.
199
223
  - Runtime detection uses Bun globals and process properties
200
224
  - CI provider detection checks environment variables specific to each CI system
201
225
  - The `StacksEnv` type provides autocomplete for 100+ known variables
226
+ - Every variable is `| undefined` - the process may simply not have it set
227
+ - `Bun.env.X` is NOT typed, and holds raw strings. `Bun.env.DEBUG` is `'false'`, which is truthy; use `env.DEBUG`, which coerces. A generator used to declare `Bun.env` from whichever `.env` was on the machine that ran it, typing `DEBUG` as `boolean` - it was removed, and nothing read it
@@ -109,16 +109,33 @@ type Off = <Key extends keyof StacksEvents>(type: Key, handler?: Handler<StacksE
109
109
  ## Built-in Event Types (StacksEvents)
110
110
 
111
111
  ```typescript
112
- interface StacksEvents extends ModelEvents, Record<EventType, unknown> {
113
- 'user:registered': Record<string, any>
114
- 'user:logged-in': Record<string, any>
115
- 'user:logged-out': Record<string, any>
116
- 'user:password-reset': Record<string, any>
117
- 'user:password-changed': Record<string, any>
112
+ // @stacksjs/events
113
+ interface AuthEvents {
114
+ 'user:registered': UserRegisteredEvent
115
+ 'user:logged-in': UserLoggedInEvent
116
+ 'user:logged-out': UserLoggedOutEvent
117
+ 'user:password-reset': UserPasswordEvent
118
+ 'user:password-changed': UserPasswordEvent
118
119
  }
120
+
121
+ // Augmentation target - model events land here, and so do yours.
122
+ interface AppEvents {}
123
+
124
+ type StacksEvents = AppEvents & AuthEvents
125
+ type EventName = keyof StacksEvents & string
119
126
  ```
120
127
 
121
- The `Record<EventType, unknown>` intersection allows arbitrary event names beyond the declared ones.
128
+ There is **no** trailing index signature, deliberately: an arbitrary event name is
129
+ what made `dispatch('user:creatd', …)` compile and reach nobody. Declare an
130
+ application's own events on `AppEvents` and the typo becomes a compile error.
131
+
132
+ ```typescript
133
+ declare module '@stacksjs/events' {
134
+ interface AppEvents {
135
+ 'invoice:settled': { id: number, total: number }
136
+ }
137
+ }
138
+ ```
122
139
 
123
140
  ## Model Events
124
141
 
@@ -188,87 +205,86 @@ answer to "which models emit events" is "the ones that exist".
188
205
  ## Event-to-Listener Mapping (app/Events.ts)
189
206
 
190
207
  ```typescript
191
- import type { Events } from '@stacksjs/types'
208
+ import { defineEvents } from '@stacksjs/events'
192
209
 
193
- export default {
210
+ export default defineEvents({
194
211
  'user:registered': ['SendWelcomeEmail'],
195
212
  'user:created': ['NotifyUser'],
196
- } satisfies Events
213
+ })
197
214
  ```
198
215
 
199
- Keys are event names (must match StacksEvents keys). Values are arrays of **Action names** -- these correspond to files in `app/Actions/`.
216
+ Both halves are checked. A key must be an event that exists (`EventName`, above);
217
+ a value must name a listener that is on disk (`ListenerName`, generated into
218
+ `storage/framework/types/actions.d.ts` from `app/Listeners/`, `app/Actions/` and
219
+ the framework defaults behind them). `satisfies Events` is equivalent and still
220
+ supported; `defineEvents` is preferred because it also keeps the literal types,
221
+ so `keyof typeof events` is the two names the file declares rather than `string`.
200
222
 
201
223
  ## Listener Resolution (app/Listener.ts)
202
224
 
203
- The `handleEvents()` function sets up the entire event-to-action pipeline:
225
+ `handleEvents()` delegates to `registerAppListeners()`, which registers both
226
+ conventions and is idempotent:
204
227
 
205
- ### Setup
206
- ```typescript
207
- export async function handleEvents() {
208
- emitter.on('*', listenEvents as WildcardHandler<StacksEvents>)
209
- }
210
- ```
211
- Subscribes a single wildcard handler that intercepts ALL events.
212
-
213
- ### Event Processing Flow
214
- 1. **Fast path**: `eventTypes` Set (pre-computed from `Object.keys(events)`) provides O(1) lookup. Events not in the map are skipped immediately.
215
- 2. **Listener resolution**: For each listener name in the array, `resolveAction(listener)` is called:
216
- - Checks `actionCache` Map (in-memory module cache)
217
- - Checks `pendingImports` Map (deduplicates concurrent imports of the same action)
218
- - Dynamically imports `app/Actions/{listener}.ts`
219
- - Validates the module exports a `handle(event)` method
220
- - Caches the resolved module in `actionCache`
221
- 3. **Execution**: `processListeners()` iterates listeners sequentially (`for...of`), awaiting each action's `handle(event)` method
222
- 4. **Error handling**: Errors are caught per-listener via `handleError()` from `@stacksjs/error-handling` -- one listener failure does not prevent others
223
-
224
- ### Caching Details
225
228
  ```typescript
226
- const actionCache = new Map<string, { handle: (event: any) => Promise<any> | any }>()
227
- const pendingImports = new Map<string, Promise<...>>()
228
- const eventTypes = new Set(Object.keys(events)) // pre-computed at module load
229
- ```
230
-
231
- - `actionCache`: stores resolved action modules permanently
232
- - `pendingImports`: prevents double-importing when multiple events fire simultaneously for the same listener
233
- - `eventTypes`: O(1) lookup to skip events with no registered listeners
229
+ import { registerAppListeners } from '@stacksjs/events'
230
+ import { path as p } from '@stacksjs/path'
234
231
 
235
- ### Listener Type Support
236
- The listener can also be a function (not just a string):
237
- ```typescript
238
- if (typeof listener === 'function') {
239
- await listener(event)
240
- continue
232
+ export async function handleEvents(): Promise<number> {
233
+ return registerAppListeners({ base: p.projectPath() })
241
234
  }
242
235
  ```
243
236
 
237
+ Names in the map resolve against `app/Listeners/`, `app/Actions/`, then the same
238
+ two directories under the framework defaults - first match wins.
239
+
240
+ ### Registration Flow
241
+ 1. **The map**: `registerFromMap()` imports `app/Events.ts` and, for each name,
242
+ resolves a module with a `handle` method out of `app/Listeners/`,
243
+ `app/Actions/` or the framework defaults, then subscribes it to that event
244
+ directly. A name that resolves to nothing is warned about by name.
245
+ 2. **The scan**: `discoverListeners()` walks `app/Listeners/` and registers any
246
+ default export shaped `{ listensTo, handle }`. `listensTo` may be an array.
247
+ 3. **Dedup**: every `(event, module)` pair is claimed once per process, so a
248
+ listener that appears in both conventions - or a dev server that re-runs
249
+ boot - registers once rather than twice.
250
+ 4. **Payload check**: if the resolved action declares `validations`, a dispatched
251
+ payload that does not match them is warned about. It is not thrown: the
252
+ dispatcher has already committed the thing the event announces.
253
+
254
+ ### Where it is called from
255
+ - `injectGlobalAutoImports()` (`@stacksjs/server`), so every path that dispatches -
256
+ HTTP, `buddy seed`, a scheduled job, a console command - has listeners on the bus
257
+ - `handleEvents()` in `app/Listener.ts`, the app's own override hook
258
+
259
+ Both on the same boot in dev. The claim registry is what keeps that from being
260
+ two of every listener.
261
+
244
262
  ## Implementation Details
245
263
 
246
264
  ### Thread Safety
247
- - mitt handlers are stored in arrays -- `emit()` calls `.slice()` before iterating to safely handle additions/removals during iteration
248
- - Wildcard handler registration happens once in `handleEvents()` -- the single handler routes all events
265
+ - handlers are stored in arrays -- `emit()` calls `.slice()` before iterating to safely handle additions/removals during iteration
249
266
 
250
267
  ### Synchronous vs Asynchronous
251
- - **mitt itself is synchronous**: `emit()` calls handlers directly, does not await them
252
- - **Listener resolution is asynchronous**: `processListeners()` uses `async/await` for dynamic imports and action execution
253
- - The wildcard handler in `app/Listener.ts` calls `processListeners()` as fire-and-forget (no await) since mitt doesn't support async wildcard handlers
268
+ - **`emit` is synchronous**: it calls handlers directly and does not await them; an async handler's rejection is logged rather than lost
269
+ - **`emitAsync` / `dispatchAsync` awaits** every matching handler and resolves with their results
270
+ - **Registration is asynchronous**: resolving a listener name imports a module
254
271
 
255
272
  ### Memory
256
- - The emitter is a module-level singleton -- created once at import time
257
- - Action modules are cached permanently in `actionCache` -- hot-reloading new actions requires server restart
258
- - The `pendingImports` Map entries are cleaned up in the `finally` block of each import
273
+ - The emitter is one per *process*, keyed on `Symbol.for('stacks.events.emitter')` rather than per copy of the package -- two installed copies would otherwise be two separate buses, and a dispatch into the wrong one looks exactly like a dispatch nobody listened for
274
+ - Resolved listener modules are held by the closures registered on the emitter, so adding an action requires a restart
259
275
 
260
276
  ## Gotchas
261
277
  - Events are functional, not class-based -- no need to create event classes
262
278
  - The emitter is a **singleton** -- shared across the entire application process
263
279
  - Wildcard `'*'` listeners receive `(type, event)` -- regular handlers receive just `(event)`
264
- - Listeners in `app/Events.ts` are **Action names** (strings), not file paths or handler functions
265
- - The action module must export a default with a `handle(event)` method
266
- - Event dispatch via mitt is **synchronous** but listener action resolution (dynamic import) is **asynchronous**
280
+ - Listeners in `app/Events.ts` are **names** (strings), not file paths or handler functions -- resolved against `app/Listeners/`, `app/Actions/`, then the framework defaults
281
+ - The listener module must export a default with a `handle(event)` method
282
+ - Event dispatch is **synchronous** but listener resolution (dynamic import) happens once, at boot
267
283
  - Model events only fire when the model has `observe: true` (or array) trait set
268
284
  - The event system is ~200 bytes total -- it is intentionally minimal
269
- - Listener caching means hot-reloading new actions requires server restart
270
- - If `evt` is `undefined`, handlers are NOT called (mitt checks `if (evt !== undefined)`)
285
+ - Listeners are resolved once at boot, so adding an action requires a server restart
286
+ - If `evt` is `undefined`, handlers are NOT called (`emit` checks `if (evt !== undefined)`)
271
287
  - The `'*'` event type cannot be manually emitted -- it only receives forwarded events
272
288
  - `off(type)` without a handler argument clears ALL handlers for that type (sets to empty array, not delete)
273
- - The `StacksEvents` interface extends `Record<EventType, unknown>` allowing any string as an event name
289
+ - `StacksEvents` has **no** index signature: an undeclared event name is a compile error, not a dispatch into the void. Declare your own on `AppEvents`
274
290
  - Error logging in mitt uses `console.error` (not `@stacksjs/logging`) to avoid circular dependencies
@@ -82,6 +82,9 @@ await SendWelcomeEmail.dispatchNow({ email, name })
82
82
  ```typescript
83
83
  import { job } from '@stacksjs/queue'
84
84
 
85
+ // Checked against `app/Jobs/` AND the framework defaults - the same three
86
+ // directories `resolveJobFile` searches, so the jobs Stacks ships are
87
+ // dispatchable and schedulable by name too.
85
88
  await job('SendWelcomeEmail', { email, name })
86
89
  .onQueue('emails')
87
90
  .delay(60)
@@ -18,23 +18,30 @@ Application-level event listeners in `app/Listeners/`.
18
18
  ## Event → Listener Mapping (app/Events.ts)
19
19
 
20
20
  ```typescript
21
- export default {
21
+ import { defineEvents } from '@stacksjs/events'
22
+
23
+ export default defineEvents({
22
24
  'user:registered': ['SendWelcomeEmail'], // triggers app/Actions/SendWelcomeEmail.ts
23
25
  'user:created': ['NotifyUser'], // triggers app/Actions/NotifyUser.ts
24
26
  'order:created': ['ProcessPayment', 'SendOrderConfirmation'], // multiple listeners
25
- } satisfies Events
27
+ })
26
28
  ```
27
29
 
28
- Keys are event names. Values are arrays of Action names from `app/Actions/`.
30
+ Keys must be event names that exist; values must name listeners that exist. Both
31
+ are compile errors otherwise, which matters here more than most places: a name
32
+ that resolves to nothing produces one line in boot output and then behaves
33
+ exactly like an event nobody cared about.
29
34
 
30
35
  ## How Listeners Work
31
36
 
32
37
  1. Events are dispatched: `dispatch('user:registered', { id: 1 })`
33
- 2. The wildcard handler in `app/Listener.ts` catches ALL events
34
- 3. Checks if the event has registered listeners in `app/Events.ts`
35
- 4. For each listener, dynamically imports `app/Actions/{listener}.ts`
36
- 5. Calls `action.handle(eventData)`
37
- 6. Action modules are cached after first import
38
+ 2. At boot, `registerAppListeners()` reads `app/Events.ts` and subscribes each
39
+ named listener to its event directly
40
+ 3. Each name resolves against `app/Listeners/`, then `app/Actions/`, then the
41
+ same two under the framework defaults - first match wins
42
+ 4. Modules under `app/Listeners/` that declare their own `listensTo` are
43
+ registered by the same call, from a directory scan
44
+ 5. A listener that appears in both is registered once
38
45
 
39
46
  ## Creating a Listener Action
40
47
 
@@ -43,7 +50,7 @@ Keys are event names. Values are arrays of Action names from `app/Actions/`.
43
50
  export default {
44
51
  name: 'SendWelcomeEmail',
45
52
 
46
- async handle(event: { id: number; email: string; name: string }) {
53
+ async handle(event: { id: number, email: string, name: string }) {
47
54
  // event contains the data passed to dispatch()
48
55
  await sendWelcomeEmail({ to: event.email, name: event.name })
49
56
  return { success: true }
@@ -51,6 +58,29 @@ export default {
51
58
  }
52
59
  ```
53
60
 
61
+ ## Creating a Standalone Listener
62
+
63
+ A module under `app/Listeners/` that declares its own `listensTo` is registered
64
+ by the boot scan, with no entry in `app/Events.ts`. Use `defineListener` so the
65
+ event name is checked and the payload is inferred from it:
66
+
67
+ ```typescript
68
+ // app/Listeners/SendWelcomeEmail.ts
69
+ import { defineListener } from '@stacksjs/events'
70
+
71
+ export default defineListener({
72
+ listensTo: 'user:registered', // or ['user:created', 'user:updated']
73
+ handle: async (user, event) => { // `user` is typed from the event name
74
+ await sendWelcomeEmail({ to: user.email })
75
+ void event // which event fired, for multi-event listeners
76
+ },
77
+ })
78
+ ```
79
+
80
+ A glob (`'user:*'`, `'*'`) is a legal subscription and receives the union of
81
+ what the bus carries. A listener that appears here *and* in `app/Events.ts` is
82
+ registered once.
83
+
54
84
  ## CLI Event Listeners (app/Listeners/Console.ts)
55
85
 
56
86
  For CLI-specific events (not HTTP):
@@ -83,16 +83,20 @@ throw new Response(JSON.stringify({ error: 'Rate limited' }), {
83
83
  Maps short names to middleware class filenames:
84
84
 
85
85
  ```typescript
86
- export default {
86
+ import { defineMiddleware } from '@stacksjs/router'
87
+
88
+ export default defineMiddleware({
87
89
  'maintenance': 'Maintenance',
88
90
  'auth': 'Auth',
89
91
  'guest': 'Guest',
90
92
  'api': 'Api',
91
93
  'team': 'Team',
94
+ 'site': 'Site',
92
95
  'logger': 'Logger',
93
96
  'abilities': 'Abilities',
94
97
  'can': 'Can',
95
98
  'throttle': 'Throttle',
99
+ 'signed': 'Signed',
96
100
  'env': 'Env',
97
101
  'env:local': 'EnvLocal',
98
102
  'env:development': 'EnvDevelopment',
@@ -103,9 +107,36 @@ export default {
103
107
  'role': 'Role',
104
108
  'permission': 'Permission',
105
109
  'verified': 'EnsureEmailIsVerified',
106
- } satisfies Middleware
110
+ })
107
111
  ```
108
112
 
113
+ The alias is yours to invent; the class name is checked against
114
+ `app/Middleware/` and the framework defaults, so `{ auth: 'Auht' }` is a
115
+ compile error rather than a route whose guard resolves to nothing.
116
+
117
+ This map is **merged over** the framework defaults, not a replacement for them,
118
+ so an alias Stacks adds later is available without editing the file.
119
+
120
+ ## Reference forms
121
+
122
+ Three shapes are read off a reference, in this order:
123
+
124
+ | Written | Means |
125
+ |---|---|
126
+ | `'auth'` | the alias, or a class name if no alias matches (`'signed'` → `Signed`) |
127
+ | `'!auth'` | inverted: the route passes only when `auth` refuses |
128
+ | `'throttle:60,1'` | `throttle` with `60,1` in `request._middlewareParams.throttle` |
129
+
130
+ The **whole** reference is looked up as an alias before the colon is treated as
131
+ a parameter separator. That is what makes `'env:production'` its own alias
132
+ rather than `env` with a parameter - `Env` ignores parameters and accepts every
133
+ known environment, so splitting first turned a production-only route into an
134
+ unguarded one.
135
+
136
+ Inversion counts a `Response` or a status-carrying error as a refusal, and
137
+ nothing else. A `TypeError` from a bug inside `Auth` is a crash, not a
138
+ declination, and must not let `!auth` through.
139
+
109
140
  ## Applying Middleware
110
141
 
111
142
  ### Per-Route (Chainable)
@@ -163,18 +194,24 @@ Parameters are stored on `request._middlewareParams[middlewareName]` and parsed
163
194
 
164
195
  ### Environment Negation Variants
165
196
 
166
- Files exist for `EnvNotLocal`, `EnvNotDevelopment`, `EnvNotStaging`, `EnvNotProduction` — but they have **no aliases registered** in `app/Middleware.ts`.
197
+ `EnvNotLocal`, `EnvNotDevelopment`, `EnvNotStaging` and `EnvNotProduction` exist
198
+ as classes with no alias. Reference them by class name, or write the negated
199
+ form of the positive alias - `'!env:production'` is `EnvNotProduction`.
167
200
 
168
201
  ## Middleware Loading Flow
169
202
 
170
203
  ```
171
- 1. Parse middleware name: 'throttle:60,1' → { name: 'throttle', params: '60,1' }
172
- 2. Check middleware cache (loaded once, cached for performance)
173
- 3. Resolve alias: 'throttle' → 'Throttle' (via app/Middleware.ts)
174
- 4. Try loading from app/Middleware/Throttle.ts (user overrides)
175
- 5. Fall back to storage/framework/defaults/app/Middleware/Throttle.ts
176
- 6. Store params on request: request._middlewareParams.throttle = '60,1'
177
- 7. Execute: await middleware.handle(enhancedRequest)
204
+ 1. Strip a leading '!', if any, and remember it
205
+ 2. Look the WHOLE remainder up in the merged alias map
206
+ 'env:production' is an alias → { name: 'env:production' }
207
+ 'throttle:60,1' is not → { name: 'throttle', params: '60,1' }
208
+ 3. Check middleware cache (loaded once, cached for performance)
209
+ 4. Resolve alias: 'throttle' → 'Throttle'; unaliased names PascalCase
210
+ 5. Try loading from app/Middleware/Throttle.ts (user overrides)
211
+ 6. Fall back to storage/framework/defaults/app/Middleware/Throttle.ts
212
+ 7. Store params on request: request._middlewareParams.throttle = '60,1'
213
+ 8. Wrap in the inverter if step 1 saw a '!'
214
+ 9. Sort the chain by priority, then execute: await middleware.handle(req)
178
215
  ```
179
216
 
180
217
  User middleware in `app/Middleware/` always takes precedence over framework defaults.
@@ -239,10 +276,11 @@ import { authMiddleware, authMiddlewareHandler } from '@stacksjs/auth'
239
276
  ```
240
277
 
241
278
  ## Gotchas
242
- - **Priority exists but isn't used for ordering** — the router executes middleware in registration order, not priority order
279
+ - **Priority DOES order the chain** — entries are sorted by `priority` (lower first, default 10) before execution, so CORS can precede auth regardless of the order they were attached in. An earlier version of this file said otherwise. A non-finite or negative value is clamped to the default and warned about once
243
280
  - **`terminate()` doesn't exist** — some docs reference it, but it's not in the actual `MiddlewareConfig` interface
244
281
  - **Two auth middleware implementations** — defaults version (basic token check) and `@stacksjs/auth` version (full user loading)
245
- - **EnvNot* files have no aliases** — `EnvNotLocal.ts`, etc. exist but aren't registered in `app/Middleware.ts`
282
+ - **EnvNot* files have no aliases** — reachable by class name, or as `'!env:production'` and friends
283
+ - **The alias map merges over the defaults** — an app's `app/Middleware.ts` adds to and overrides them rather than replacing the set
246
284
  - **Middleware is cached after first load** — changes require server restart
247
285
  - **User overrides take precedence** — `app/Middleware/Auth.ts` replaces the framework default completely
248
286
  - **Group middleware accumulates** — nested groups combine all parent middleware
@@ -20,6 +20,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
20
20
  queue/src/
21
21
  ├── action.ts # Job class with dispatch/dispatchIf/dispatchAfter/dispatchNow
22
22
  ├── job.ts # JobBuilder fluent API + job() helper + jobBatch() + runJob()
23
+ │ # also `Jobs` (the JobName registry) and `resolveJobFile`
23
24
  ├── discovery.ts # Job auto-discovery from app/Jobs/ via Bun.Glob
24
25
  ├── scheduler.ts # Cron-based job scheduling with overlap prevention
25
26
  ├── worker.ts # Queue worker (database polling & Redis processing)
@@ -75,6 +76,10 @@ export class Job {
75
76
  ```typescript
76
77
  import { job } from '@stacksjs/queue'
77
78
 
79
+ // The name is checked: `job('SendWelcomeEmial')` is a compile error, not a
80
+ // queue row no worker can resolve. `dispatch()` does not look the job up - only
81
+ // the worker does - so an unresolvable name used to enqueue successfully and
82
+ // fail later, out of sight of the caller.
78
83
  await job('SendWelcomeEmail', { email, name })
79
84
  .onQueue('emails')
80
85
  .delay(60) // seconds
@@ -15,7 +15,8 @@ Built on `@stacksjs/bun-router` with `ts-rate-limiter`.
15
15
  - Route files: `routes/` (api.ts, v1.ts, buddy.ts, users.ts)
16
16
  - Route registry: `app/Routes.ts`
17
17
  - Generated route manifest: `storage/framework/stx/routes.ts` (written by the dev server)
18
- - Generated action paths: `storage/framework/types/actions.d.ts`
18
+ - Derived name types: `storage/framework/types/registries.d.ts`
19
+ - The maps they read: `storage/framework/auto-imports/{actions,listeners,policies,middleware,emails,routes}.ts`
19
20
 
20
21
  ## Route Definition
21
22
 
@@ -56,12 +57,20 @@ route.group({ prefix: '/api/v1', middleware: ['auth', 'throttle'] }, () => {
56
57
  - Action object: an imported action, passed directly — see typed routes below
57
58
  - Controller: `'Controllers/UserController@index'` — calls controller method
58
59
 
59
- ## The strings are typed (run `buddy generate:types`)
60
+ ## The strings are typed
60
61
 
61
62
  Action paths, middleware aliases and route names are all checked at compile
62
- time against what this application actually has. `buddy generate:types`
63
- discovers them and writes them into the router's type registry
64
- (`storage/framework/types/actions.d.ts`); nothing is maintained by hand.
63
+ time against what this application actually has, and nothing is maintained by
64
+ hand - or generated as a type.
65
+
66
+ Each of them is `keyof` over a map the RESOLVER reads:
67
+ `storage/framework/auto-imports/{actions,middleware,routes}.ts`, name to file,
68
+ written by `buddy generate` alongside the models and jobs barrels. So a name
69
+ that type-checks is a name that resolves; there is no second list to go stale.
70
+ `storage/framework/types/registries.d.ts` is where the derivation lives.
71
+
72
+ Middleware aliases need no map at all - `app/Middleware.ts` and the framework's
73
+ own are ordinary modules, and `defineMiddleware` keeps their literal keys.
65
74
 
66
75
  ```typescript
67
76
  route.get('/login', 'Actions/Auth/LogniAction') // ✗ no such action
@@ -78,8 +87,9 @@ Notes:
78
87
  member name, not a filename.
79
88
  - Negated (`'!auth'`) and parameterised (`'throttle:60,1'`) middleware forms are
80
89
  both accepted.
81
- - Regenerate after adding an action, a middleware alias, or a `.name()`. A stale
82
- file rejects code that is correct.
90
+ - The maps refresh on `buddy generate`, `buddy generate:types` and dev-server
91
+ boot, and the staleness check watches the directories they are built from. A
92
+ map written before a file was added rejects code that is correct.
83
93
  - `resource()` takes a BASE, and composes `Actions/<Base><Kind>Action` from it.
84
94
  `route.resource('posts', 'Post')` → `Actions/PostIndexAction`, matching where
85
95
  `buddy make:crud` writes. The base is checked against the actions that exist;
@@ -47,7 +47,7 @@ The `Schedule` class is the core scheduling API. The lowercase `schedule` export
47
47
  import { schedule } from '@stacksjs/scheduler'
48
48
 
49
49
  // Static factory methods — each returns UntimedSchedule
50
- schedule.job(name: string): UntimedSchedule // Runs a job by name via runJob()
50
+ schedule.job(name: JobName): UntimedSchedule // Runs a job by name via runJob()
51
51
  schedule.action(name: string): UntimedSchedule // Runs an action by name via runAction()
52
52
  schedule.command(cmd: string): UntimedSchedule // Runs a shell command via runCommand()
53
53
 
@@ -21,7 +21,7 @@ export default new Action({
21
21
  return {
22
22
  records,
23
23
  summary: summarizeCommerceCustomers(records),
24
- currency: normalizeCommerceCustomerCurrency((config as any).commerce?.currency),
24
+ currency: normalizeCommerceCustomerCurrency(config.commerce?.currency),
25
25
  }
26
26
  }
27
27
  catch (error) {