@stacksjs/defaults 0.75.230 → 0.75.233

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 (51) hide show
  1. package/ai/skills/stacks-dashboard/SKILL.md +15 -0
  2. package/ai/skills/stacks-models/SKILL.md +6 -1
  3. package/ai/skills/stacks-native/CATALOG.md +6 -6
  4. package/ai/skills/stacks-storage/SKILL.md +43 -0
  5. package/ai/skills/stacks-testing/SKILL.md +38 -10
  6. package/app/Actions/Dashboard/Models/GetModels.ts +19 -14
  7. package/app/Actions/Dashboard/Models/GetSubscriberCount.ts +5 -2
  8. package/app/Actions/Dashboard/Models/GetUserCount.ts +5 -2
  9. package/app/Actions/Dashboard/Models/ModelShowAction.ts +3 -11
  10. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +6 -3
  11. package/app/Actions/Dashboard/Models/model-write.ts +2 -3
  12. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +6 -2
  13. package/functions/dashboard-form.test.ts +41 -0
  14. package/functions/dashboard-form.ts +30 -0
  15. package/functions/dashboard-list.test.ts +228 -0
  16. package/functions/dashboard-list.ts +257 -0
  17. package/ide/vscode/package.json +1 -1
  18. package/package.json +2 -2
  19. package/project/storage/framework/types/browser-composables.d.ts +56 -0
  20. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +2 -7
  21. package/resources/components/Dashboard/Commerce/CommerceCategoryDialog.stx +2 -4
  22. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +2 -4
  23. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +2 -7
  24. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +2 -4
  25. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +2 -7
  26. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +2 -4
  27. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +2 -7
  28. package/resources/components/Dashboard/Commerce/CommerceManufacturerDialog.stx +2 -4
  29. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +2 -4
  30. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +2 -7
  31. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +2 -4
  32. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +2 -7
  33. package/resources/components/Dashboard/Commerce/CommerceReviewEditDialog.stx +2 -4
  34. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +2 -4
  35. package/resources/components/Dashboard/Commerce/CommerceUnitDialog.stx +2 -4
  36. package/resources/components/Dashboard/Commerce/CommerceVariantDialog.stx +2 -4
  37. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +45 -92
  38. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +46 -92
  39. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +46 -92
  40. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +44 -90
  41. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +45 -91
  42. package/resources/components/Dashboard/Commerce/PrintDeviceDialog.stx +2 -4
  43. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +2 -7
  44. package/resources/components/Dashboard/Commerce/ProductWaitlistEntryDialog.stx +2 -4
  45. package/resources/components/Dashboard/Commerce/RestaurantWaitlistEntryDialog.stx +2 -4
  46. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +2 -9
  47. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +2 -9
  48. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +2 -9
  49. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +2 -9
  50. package/resources/functions/dashboard/model-access.ts +15 -0
  51. package/resources/plugins/preloader.ts +9 -1
@@ -652,3 +652,18 @@ or a fake success response.
652
652
  - Preserve the existing sidebar information architecture and styling when redesigning page content
653
653
  - Use `dashboard-modal-layer` for every fixed dashboard dialog or drawer
654
654
  - The live terminal component polls real deployment output and pauses while the document is hidden
655
+
656
+ ## Generic model exposure
657
+
658
+ Set `dashboard: { access: false }` on a model to exclude it server-side from
659
+ the generic model catalog and its totals, legacy highlighted model counts,
660
+ User/Subscriber count Actions, global model search, and generic model-browser
661
+ read/store/update/destroy Actions. Direct model-browser requests return 404
662
+ before row queries. Omission preserves existing exposure.
663
+
664
+ `dashboard.enabled: false` only hides a sidebar row and still permits generic
665
+ model access. `access` does not disable direct ORM calls, named domain Actions,
666
+ or separate routes generated by `useApi`. Configure `security.api.models` or
667
+ `STACKS_MODEL_APIS`, middleware, ownership and policies for those APIs. The
668
+ new boundary does not provide row ownership or permission checks for models
669
+ that remain exposed.
@@ -123,7 +123,12 @@ of forcing a cast. See `orm/tests/inference-attributes.test-d.ts`.
123
123
 
124
124
  Also at the top level: `indexes: [{ name, columns, unique?, where? }]` for
125
125
  composite and partial-unique indexes, and `dashboard: { highlight: true }` to
126
- feature the model in the admin UI.
126
+ feature the model in the admin UI. Use `dashboard: { access: false }` to
127
+ exclude a model from generic dashboard catalogs/counts/search and the generic
128
+ model-browser CRUD Actions before database work. Its default is `true`;
129
+ `dashboard.enabled: false` still only hides the sidebar row. `access` leaves
130
+ direct ORM calls, named domain Actions and generated `useApi` routes available.
131
+ Those APIs require their own route selection, middleware, ownership and policies.
127
132
 
128
133
  `useApi` is an API capability, not a dashboard-view generator. Its generated
129
134
  routes are registered from the merged model registry. Framework defaults are
@@ -11,7 +11,7 @@ Browser runtime bindings are documented separately in `stacks-auto-imports`.
11
11
  | Package | Task guidance | Source entry | Retained tests |
12
12
  |---|---|---|---|
13
13
  | `@stacksjs/action-runner` | [`stacks-actions`](../stacks-actions/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/action-runner/src/index.ts) | 1 |
14
- | `@stacksjs/actions` | [`stacks-actions`](../stacks-actions/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/index.ts) | 72 |
14
+ | `@stacksjs/actions` | [`stacks-actions`](../stacks-actions/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/index.ts) | 73 |
15
15
  | `@stacksjs/ai` | [`stacks-ai`](../stacks-ai/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/ai/src/index.ts) | 17 |
16
16
  | `@stacksjs/alias` | [`stacks-alias`](../stacks-alias/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/alias/src/index.ts) | 1 |
17
17
  | `@stacksjs/analytics` | [`stacks-analytics`](../stacks-analytics/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/analytics/src/index.ts) | 6 |
@@ -86,9 +86,9 @@ Browser runtime bindings are documented separately in `stacks-auto-imports`.
86
86
  | `@stacksjs/slug` | [`stacks-slug`](../stacks-slug/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/slug/src/index.ts) | 4 |
87
87
  | `@stacksjs/sms` | [`stacks-sms`](../stacks-sms/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/sms/src/index.ts) | 5 |
88
88
  | `@stacksjs/socials` | [`stacks-socials`](../stacks-socials/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/socials/src/index.ts) | 17 |
89
- | `@stacksjs/storage` | [`stacks-storage`](../stacks-storage/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/index.ts) | 40 |
89
+ | `@stacksjs/storage` | [`stacks-storage`](../stacks-storage/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/index.ts) | 42 |
90
90
  | `@stacksjs/strings` | [`stacks-strings`](../stacks-strings/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/strings/src/index.ts) | 7 |
91
- | `@stacksjs/testing` | [`stacks-testing`](../stacks-testing/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/index.ts) | 14 |
91
+ | `@stacksjs/testing` | [`stacks-testing`](../stacks-testing/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/index.ts) | 15 |
92
92
  | `@stacksjs/tinker` | [`stacks-repl`](../stacks-repl/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/tinker/src/index.ts) | 1 |
93
93
  | `@stacksjs/tunnel` | [`stacks-tunnel`](../stacks-tunnel/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/tunnel/src/index.ts) | 1 |
94
94
  | `@stacksjs/types` | [`stacks-types`](../stacks-types/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/types/src/index.ts) | 2 |
@@ -119,7 +119,7 @@ Declared runtime exports: `BlogAdminError`, `DEFAULT_IOS_DEPENDENCIES`, `DEFAULT
119
119
 
120
120
  Local source modules: [src/action.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/action.ts), [src/add.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/add.ts), [src/blog-admin.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/blog-admin.ts), [src/build/ios-toolchain.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/build/ios-toolchain.ts), [src/commit.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/commit.ts), [src/dev/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/dev/index.ts), [src/discover-packages.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/discover-packages.ts), [src/generate/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/generate/index.ts), [src/github-release.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/github-release.ts), [src/helpers/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/helpers/index.ts), [src/library/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/library/index.ts), [src/lint/lint.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/lint/lint.ts), [src/lint/stx-gate.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/lint/stx-gate.ts), [src/make-command.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/make-command.ts), [src/make-job.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/make-job.ts), [src/make-policy.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/make-policy.ts), [src/make-resource.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/make-resource.ts), [src/make.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/make.ts), [src/scaffold-crud.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/scaffold-crud.ts), [src/setup/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/setup/index.ts), [src/stacks.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/stacks.ts), [src/upgrade/package-project.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/upgrade/package-project.ts), [src/upgrade/route-actions.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/src/upgrade/route-actions.ts).
121
121
 
122
- Evidence directory: [72 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
122
+ Evidence directory: [73 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/actions/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
123
123
 
124
124
  ### @stacksjs/ai
125
125
 
@@ -831,7 +831,7 @@ Declared runtime exports: `PathSanitizeError`, `Storage`, `StorageManager`, `Upl
831
831
 
832
832
  Local source modules: [src/adapters/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/adapters/index.ts), [src/bounded-text-file.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/bounded-text-file.ts), [src/completed-artifact-directory.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/completed-artifact-directory.ts), [src/copy.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/copy.ts), [src/delete.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/delete.ts), [src/drivers/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/drivers/index.ts), [src/facade.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/facade.ts), [src/file-info.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/file-info.ts), [src/files.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/files.ts), [src/folders.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/folders.ts), [src/fs.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/fs.ts), [src/glob.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/glob.ts), [src/hash.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/hash.ts), [src/helpers.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/helpers.ts), [src/mime-verify.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/mime-verify.ts), [src/path-sanitize.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/path-sanitize.ts), [src/provision.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/provision.ts), [src/s3-presigned-post.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/s3-presigned-post.ts), [src/signed-url.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/signed-url.ts), [src/static-serve.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/static-serve.ts), [src/storage.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/storage.ts), [src/temporary-workspaces.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/temporary-workspaces.ts), [src/types.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/types.ts), [src/types/filesystem.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/types/filesystem.ts), [src/uploaded-file.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/uploaded-file.ts), [src/zip.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/src/zip.ts).
833
833
 
834
- Evidence directory: [40 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
834
+ Evidence directory: [42 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/storage/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
835
835
 
836
836
  ### @stacksjs/strings
837
837
 
@@ -851,7 +851,7 @@ Read the entrypoint and package export map for re-exported or type-only surfaces
851
851
 
852
852
  Local source modules: [src/console.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/console.ts), [src/disposable-database.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/disposable-database.ts), [src/events.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/events.ts), [src/feature.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/feature.ts), [src/isolated.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/isolated.ts), [src/mail.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/mail.ts), [src/time.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/src/time.ts).
853
853
 
854
- Evidence directory: [14 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
854
+ Evidence directory: [15 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/testing/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
855
855
 
856
856
  ### @stacksjs/tinker
857
857
 
@@ -85,6 +85,36 @@ parent-directory authorization and domain validation. This is a local hard-link
85
85
  and directory-fsync protocol, not a cloud-adapter transaction or tested hardware
86
86
  power-loss guarantee.
87
87
 
88
+ For a durable ownership checkpoint before private bytes, use
89
+ `prepareCompletedArtifactDirectorySync(directory, entries, manifestJson, options)`
90
+ from the same subpath or package root. Its deeply frozen handle contains only
91
+ `receipt` and single-use `publishSync()`. Preparation exclusively creates and
92
+ syncs a private empty tree, then closes every descriptor. Persist the receipt
93
+ before calling the same live handle's publication method. The receipt records
94
+ canonical parent/tree/file identities and planned lengths/SHA-256; observed
95
+ file lengths are zero and `manifest.json` is absent. Planned metadata is not
96
+ completed content or a deletion receipt.
97
+
98
+ Pass plain data records/lists and unshared Uint8Array/Buffer views. Record/list
99
+ validation refuses proxies, getters, holes and hidden/symbol aliases without
100
+ executing caller code; byte views use intrinsic offsets rather than overrides.
101
+ Publication rechecks the empty tree and identities, writes/syncs
102
+ detached bytes, verifies readback hashes and exclusively links the registered
103
+ pending-manifest inode to completion. Every attempt consumes the handle, even
104
+ on error. Errors preserve state; JSON cannot rehydrate a writer. There is no
105
+ overwrite, arbitrary existing-directory writer or implicit cleanup.
106
+
107
+ Read the storage README's prepared-artifact section before integrating an
108
+ ownership ledger. It defines decimal-string identities, planned byte semantics,
109
+ birth-time availability, prepublication-only change time and legitimate link
110
+ transitions. Parent paths must already exist, belong to the operator and remain
111
+ controlled. Canonical ancestor aliases are allowed; final targets are exclusive.
112
+ Both calls block the isolate, including while holding any caller transaction.
113
+ Caps bound bytes/files rather than I/O duration; database contention and SQL/FS
114
+ atomicity remain caller concerns. The existing async writer keeps asynchronous
115
+ filesystem execution. Real process-interruption tests do not establish hardware
116
+ power-loss, hostile same-user race, deletion or media-erasure guarantees.
117
+
88
118
  ### Recoverable temporary workspaces
89
119
 
90
120
  Use `TemporaryWorkspaceJournal` for workers that recover temporary directories after a process interruption. Keep its private journal outside job-controlled checkouts and separate it by stable worker and controller identity. `create(key)` returns a frozen live ownership handle; stop its resources, then call `complete(handle)` for normal disposal. A restarted instance cannot forge this capability from a listed record.
@@ -106,6 +136,19 @@ await journal.retire(recovered.id, record => controllerConfirmsFinished(record.k
106
136
 
107
137
  The `Storage` singleton is a pre-instantiated `StorageManager`. It lazily builds its config from `@stacksjs/config` (which reads `config/filesystems.ts` and env vars).
108
138
 
139
+ ### Directory mounts without retirement
140
+
141
+ Use `inspectDirectoryMounts(canonicalDirectory)` for read-only current-namespace
142
+ mount checks where legitimate readers may stay open. Supply an existing,
143
+ checked canonical directory. An unmounted result means only that the native
144
+ mount table did not reference that directory or a descendant. Unavailable,
145
+ unsupported, aliased or missing paths never return `unmounted: true`.
146
+
147
+ Keep `inspectTemporaryWorkspace` and journal retirement for deletion: they also
148
+ check process references and inspection permissions. Mount inspection alone
149
+ grants no ownership, quiescence or retirement authority and does not inspect
150
+ other namespaces or prevent later host changes.
151
+
109
152
  ### Basic Operations
110
153
 
111
154
  ```typescript
@@ -169,7 +169,8 @@ expect(result.exitCode).toBe(0)
169
169
 
170
170
  Retained implementation evidence lives under `core/testing/tests/`:
171
171
  `http-client`, `acting-as`, `database-assertions`, `events`, `mail`, `time`,
172
- `console`, `isolated`, `disposable-database` and `browser` test files. Fixture mechanics live in `core/testing/src/`;
172
+ `console`, `isolated`, `disposable-database`, `browser` and `declarations` (what dtsx
173
+ publishes for `browser.ts` / `isolated.ts`: no `unknown` fields) test files. Fixture mechanics live in `core/testing/src/`;
173
174
  `package.json` declares the root and database/DynamoDB/browser export boundaries.
174
175
 
175
176
  ## Isolated processes and disposable databases
@@ -191,7 +192,7 @@ for (const dialect of ['sqlite', 'postgres'] as const) {
191
192
  env: { APP_ENV: 'test', ...database.env },
192
193
  timeoutMs: 45_000,
193
194
  envFile: false, // --no-env-file
194
- preload: [], // replace bunfig preloads (none)
195
+ preload: [], // replace bunfig preloads (none); or bunfig: 'tests/isolated/bunfig.toml'
195
196
  })
196
197
  assertIsolatedOk(result, 'checkout flow OK') // throws with stdout + stderr otherwise
197
198
  })
@@ -199,10 +200,14 @@ for (const dialect of ['sqlite', 'postgres'] as const) {
199
200
  }
200
201
  ```
201
202
 
202
- - `runIsolated(file, { env, timeoutMs = 60_000, args, cwd, mode, preload, envFile, inheritEnv, stdin })`
203
+ - `runIsolated(file, { env, timeoutMs = 60_000, args, cwd, mode, preload, bunfig, envFile, inheritEnv, stdin })`
203
204
  resolves `{ code, signal, stdout, stderr, timedOut, durationMs, command }`. `mode: 'auto'` runs
204
205
  `*.test.*` / `*.spec.*` with `bun test`, anything else as a script; `env` values of `undefined`
205
206
  remove a variable. The watchdog sends SIGTERM at the timeout and SIGKILL 2 s later.
207
+ - `bunfig: 'path/bunfig.toml'` runs the child with that config (`--config=`), resolved against
208
+ `cwd`; it must exist. `preload` instead generates a config holding only those preloads. They are
209
+ exclusive (a TypeError): list the preloads in your bunfig. Bun resolves a bunfig's relative
210
+ `preload` paths against the child's cwd, not the bunfig's directory.
206
211
  - `withDisposableDatabase(dialect, fn, { url?, prefix? })`: SQLite is a file in a fresh temp
207
212
  directory; Postgres is `CREATE DATABASE` on `url` or `STACKS_TEST_POSTGRES_URL`, dropped with
208
213
  `DROP DATABASE ... WITH (FORCE)` after `fn`, even when it throws (the test's error wins over a
@@ -236,18 +241,41 @@ expect(pageAuditClean(report)).toBe(true) // no duplicate /api/ requests, failed
236
241
  ```
237
242
 
238
243
  - `Cdp`: `send`, `evaluate`, `on(method, fn)` (any number of listeners per method; returns an
239
- unsubscribe), `off`, `once(method, match?, timeoutMs?)`, `errors`, `inFlight` (event streams
240
- leave it when their response starts, so a page with live updates can still settle), `closed`
241
- (every pending command fails at once when Chrome goes away).
244
+ unsubscribe), `off`, `once(method, match?, timeoutMs?)`, `intercept(patterns, handler)`,
245
+ `errors`, `inFlight: Map<requestId, 'METHOD url'>` (event streams leave it when their response
246
+ starts, so a page with live updates can still settle), `closed` (every pending command fails at
247
+ once when Chrome goes away).
242
248
  - `launch(options)` enables Page/Runtime/Network/Log, sets a 1440x1000 viewport, answers stx's
243
249
  live-reload stream (`*/_stx/hmr*`) with a day-long retry so a save mid-run cannot reload the
244
250
  page, accepts JavaScript dialogs and records them in `errors`, removes "HeadlessChrome" from the
245
251
  user agent, and passes `--use-mock-keychain`. `profile` keeps one profile across relaunches.
252
+ - **Pausing requests next to live reload.** Chrome keeps one `Fetch.enable` pattern set per
253
+ session (a second call replaces it, `Fetch.disable` drops all) and sends every paused request to
254
+ every listener, so the session layers them. `await cdp.intercept([{ urlPattern: '*/api/slow*' }],
255
+ (paused, cdp) => ...)` hands a handler only what its own patterns match and resolves with an
256
+ async remover; `quietLiveReload(cdp, patterns?)` is one (what `launch()` runs). Your own
257
+ `cdp.send('Fetch.enable' | 'Fetch.disable')` sets one more layer and the session enables the
258
+ union, so a journey that holds its own request needs no `quietLiveReload: false`.
259
+ `on('Fetch.requestPaused')` listeners see the requests your patterns match, not the live-reload
260
+ stream. The first answer wins: a second `continueRequest`/`fulfillRequest`/`failRequest` for one
261
+ pause resolves without reaching Chrome. A pause nobody could answer is continued; one your own
262
+ pattern holds with no listener stays held (a hanging request on purpose).
263
+ `matchesUrlPattern(pattern, url)` and `pausedRequestMatches(pattern, paused)` are Chrome's
264
+ matching rules (`*`, `?`, `\` escape; stage and resource type).
246
265
  - `settle(cdp, { quietMs, timeoutMs })` resolves false (not throws) when a page never goes quiet.
247
- - `auditPage(url, { cdp?, launch?, viewport?, isApiRequest?, beforeNavigate?, quietMs, timeoutMs })`
248
- returns `{ apiRequests, duplicates, failed, errors, settled, durationMs }` and never throws for a
249
- page problem. `beforeNavigate` is where to seed a token (`Page.addScriptToEvaluateOnNewDocument`).
250
- `formatPageAudit(report)` prints one line.
266
+ - `auditPage(url, { cdp?, launch?, viewport?, isApiRequest?, beforeNavigate?, pageLoads?, quietMs, timeoutMs })`
267
+ returns `{ url, finalUrl, apiRequests, duplicates, loads, failed, errors, settled, durationMs }`
268
+ and never throws for a page problem. `beforeNavigate` is where to seed a token
269
+ (`Page.addScriptToEvaluateOnNewDocument`). `formatPageAudit(report)` prints one line, with
270
+ `/today -> /home` when the page landed elsewhere.
271
+ - Duplicates are counted **per page load**: a page that sends you on (`/today` replacing itself with
272
+ `/home`) is two loads, so `/api/me` once in each is not a duplicate. A load starts with each new
273
+ main-frame document after the first and each same-document (history API) navigation to a
274
+ different path; a query or hash change stays in the load. `loads` lists them
275
+ (`{ url, apiRequests, duplicates }`), `duplicates` is the highest count any one load reached,
276
+ `finalUrl` is `location.href` once settled. `pageLoads: 'visit'` counts the whole visit as one
277
+ load (the old behaviour). `splitPageLoads(url, events, mode)` and `loadDuplicates(loads)` are the
278
+ pure logic. `finalUrl` and `loads` are optional on the type, so hand-built reports still compile.
251
279
  - **macOS: the installed Chrome quits about 30 s in.** GoogleUpdater SIGTERMs every process with
252
280
  Chrome's bundle id while an update is pending; to a test that looks like a hung page. When
253
281
  `CHROME_PATH` is not set, `launch()` runs `prepareMacChrome()`: a `cp -cR` clone in the temp
@@ -3,6 +3,7 @@ import { Glob } from 'bun'
3
3
  import { Action } from '@stacksjs/actions/runtime'
4
4
  import { path } from '@stacksjs/path'
5
5
  import { countRows } from '../../../../resources/functions/dashboard/data'
6
+ import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
6
7
  import { dashboardOperationalError } from '../dashboard-response'
7
8
 
8
9
  export default new Action({
@@ -16,23 +17,27 @@ export default new Action({
16
17
  const scanOptions = { cwd: path.userModelsPath(), onlyFiles: true }
17
18
  const displayModels: Model[] = []
18
19
 
19
- for await (const file of glob.scan(scanOptions)) {
20
- const model = (await import(path.userModelsPath(file))).default as Model
21
- if (model.dashboard?.highlight !== undefined && model.dashboard?.highlight !== false)
22
- displayModels.push(model)
23
- }
24
-
25
- if (!displayModels.length) {
26
- // If no models should be highlighted, we can just return up to 8 models in alphabetical order
27
20
  for await (const file of glob.scan(scanOptions)) {
28
- const model = (await import(path.userModelsPath(file))).default as Model
29
- displayModels.push(model)
21
+ const candidate = (await import(path.userModelsPath(file))).default as Model
22
+ const model = await resolveDashboardModel(candidate.name || '', candidate)
23
+ if (!model) continue
24
+ if (model.dashboard?.highlight !== undefined && model.dashboard?.highlight !== false)
25
+ displayModels.push(model)
26
+ }
27
+
28
+ if (!displayModels.length) {
29
+ // If no models should be highlighted, return up to 8 accessible models.
30
+ for await (const file of glob.scan(scanOptions)) {
31
+ const candidate = (await import(path.userModelsPath(file))).default as Model
32
+ const model = await resolveDashboardModel(candidate.name || '', candidate)
33
+ if (!model) continue
34
+ displayModels.push(model)
35
+ }
30
36
  }
31
- }
32
37
 
33
- if (displayModels.length > 8)
34
- displayModels.length = 8
35
- displayModels.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
38
+ if (displayModels.length > 8)
39
+ displayModels.length = 8
40
+ displayModels.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
36
41
 
37
42
  return await Promise.all(displayModels.map(async model => ({
38
43
  model: model.name,
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions/runtime'
2
- import { Subscriber } from '@stacksjs/orm'
2
+ import { response } from '@stacksjs/router'
3
+ import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
3
4
  import { dashboardOperationalError } from '../dashboard-response'
4
5
 
5
6
  export default new Action({
@@ -10,7 +11,9 @@ export default new Action({
10
11
 
11
12
  async handle() {
12
13
  try {
13
- return await Subscriber.count()
14
+ const Model = await resolveDashboardModel('Subscriber')
15
+ if (!Model) return response.json({ message: 'Model not found.' }, 404)
16
+ return await Model.count()
14
17
  }
15
18
  catch (error) {
16
19
  return dashboardOperationalError(error, 'Subscriber total could not be loaded.', 'GetSubscriberCount')
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions/runtime'
2
- import { User } from '@stacksjs/orm'
2
+ import { response } from '@stacksjs/router'
3
+ import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
3
4
  import { dashboardOperationalError } from '../dashboard-response'
4
5
 
5
6
  export default new Action({
@@ -10,7 +11,9 @@ export default new Action({
10
11
 
11
12
  async handle() {
12
13
  try {
13
- return await User.count()
14
+ const Model = await resolveDashboardModel('User')
15
+ if (!Model) return response.json({ message: 'Model not found.' }, 404)
16
+ return await Model.count()
14
17
  }
15
18
  catch (error) {
16
19
  return dashboardOperationalError(error, 'User total could not be loaded.', 'GetUserCount')
@@ -1,7 +1,8 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
3
  import { response as routerResponse } from '@stacksjs/router'
4
- import { loadModelIfExists, safeGet } from '../../../../resources/functions/dashboard/data'
4
+ import { safeGet } from '../../../../resources/functions/dashboard/data'
5
+ import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
5
6
  import { dashboardOperationalError } from '../dashboard-response'
6
7
  import { isValidModelSlug, type ModelCreateField, type ModelWriteCapabilities, modelCreateFields, modelSchemaColumns, modelWritableColumns, modelWriteCapabilities, slugToPascal } from './model-write'
7
8
 
@@ -159,15 +160,6 @@ export function parseFilters(raw: string | null): Record<string, string> {
159
160
  return out
160
161
  }
161
162
 
162
- function resolveOrmModel(modelName: string): any {
163
- const injected = (globalThis as Record<string, any>)[modelName]
164
- // The ORM injects Proxies that answer `undefined` for every property
165
- // until their deferred load lands, so probe for a real query method
166
- // rather than trusting the name to be bound.
167
- if (injected && typeof injected.where === 'function') return injected
168
- return null
169
- }
170
-
171
163
  export default new Action({
172
164
  name: 'Dashboard Model Show',
173
165
  description: 'Queries a single model by URL slug with paging, sorting, search and column filters.',
@@ -221,7 +213,7 @@ export default new Action({
221
213
 
222
214
  let Model: any
223
215
  try {
224
- Model = resolveOrmModel(modelName) ?? await loadModelIfExists(modelName)
216
+ Model = await resolveDashboardModel(modelName)
225
217
  }
226
218
  catch (error) {
227
219
  return dashboardOperationalError(error, `${modelName} could not be loaded.`, 'ModelShowAction.resolve')
@@ -4,6 +4,7 @@ import { join } from 'node:path'
4
4
  import process from 'node:process'
5
5
  import { countRows } from '../../../../resources/functions/dashboard/data'
6
6
  import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
7
+ import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
7
8
  import { modelApiConfiguration } from './model-api'
8
9
 
9
10
  /**
@@ -127,7 +128,7 @@ export default new Action({
127
128
  let userland: Array<Omit<DiscoveredModel, 'source'>>
128
129
  let defaults: Array<Omit<DiscoveredModel, 'source'>>
129
130
  try {
130
- userland = walkModels(userlandDir, false)
131
+ userland = walkModels(userlandDir, true)
131
132
  defaults = walkModels(defaultsDir, true)
132
133
  }
133
134
  catch (error) {
@@ -144,11 +145,12 @@ export default new Action({
144
145
  }
145
146
  }
146
147
 
147
- const enriched = await Promise.all(merged.map(async (m): Promise<ModelRow> => {
148
+ const candidates = await Promise.all(merged.map(async (m): Promise<ModelRow | null> => {
148
149
  let Model: any
149
150
  try {
150
151
  const module = await import(m.file)
151
- Model = module.default ?? module
152
+ Model = await resolveDashboardModel(m.name, module.default ?? module)
153
+ if (!Model) return null
152
154
  }
153
155
  catch (error) {
154
156
  return unavailableModelRow(
@@ -190,6 +192,7 @@ export default new Action({
190
192
  }
191
193
  }))
192
194
 
195
+ const enriched = candidates.filter((model): model is ModelRow => model !== null)
193
196
  enriched.sort((a, b) => a.name.localeCompare(b.name))
194
197
 
195
198
  const totalModels = enriched.length
@@ -6,7 +6,7 @@
6
6
  * casts and no observers; writing to it from a generic admin screen would
7
7
  * bypass every guarantee the ORM makes. Those rows stay read-only.
8
8
  */
9
- import { loadModelIfExists } from '../../../../resources/functions/dashboard/data'
9
+ import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
10
10
  import { dashboardOperationalIssue } from '../dashboard-response'
11
11
  import { modelApiConfiguration } from './model-api'
12
12
 
@@ -301,10 +301,9 @@ export async function resolveWritableModel(slug: string, operation?: ModelWriteO
301
301
  return { error: 'Model slug must be lowercase kebab-case.', status: 400 }
302
302
 
303
303
  const modelName = slugToPascal(slug)
304
- const injected = (globalThis as Record<string, any>)[modelName]
305
304
  let Model: any
306
305
  try {
307
- Model = (injected && typeof injected.where === 'function') ? injected : await loadModelIfExists(modelName)
306
+ Model = await resolveDashboardModel(modelName)
308
307
  }
309
308
  catch (error) {
310
309
  return {
@@ -4,6 +4,7 @@ import { env } from '@stacksjs/env'
4
4
  import { readdirSync } from 'node:fs'
5
5
  import { basename, extname, join } from 'node:path'
6
6
  import process from 'node:process'
7
+ import { resolveDashboardModel } from '../../../../resources/functions/dashboard/model-access'
7
8
  import { dashboardOperationalError } from '../dashboard-response'
8
9
 
9
10
  interface SearchableModel {
@@ -126,7 +127,8 @@ async function loadSearchableModels(): Promise<SearchableModel[]> {
126
127
  for (const [fallbackName, file] of merged) {
127
128
  try {
128
129
  const module = await import(file)
129
- const definition = module.default ?? module
130
+ const definition = await resolveDashboardModel(fallbackName, module.default ?? module)
131
+ if (!definition) continue
130
132
  const name = String(definition.name || fallbackName)
131
133
  const search = definition.traits?.useSearch
132
134
  if (!search || typeof search !== 'object' || !Array.isArray(search.searchable)) continue
@@ -197,13 +199,15 @@ export default new Action({
197
199
  if (!q) return { results: {} }
198
200
 
199
201
  try {
202
+ const models = await catalog()
203
+ if (models.length === 0) return { results: {}, unavailable: [] }
200
204
  const { Database } = await import('bun:sqlite')
201
205
  const db = new Database(env.DB_DATABASE_PATH || 'database/stacks.sqlite', { readonly: true })
202
206
  const results: Record<string, SearchResult[]> = {}
203
207
  const unavailable: SearchUnavailable[] = []
204
208
 
205
209
  try {
206
- for (const model of await catalog()) {
210
+ for (const model of models) {
207
211
  if (Object.keys(results).length >= MAX_GROUPS) break
208
212
 
209
213
  const columns = db.query(`PRAGMA table_info(${model.table})`).all() as Array<{ name: string }>
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { formErrorMessages } from './dashboard-form'
3
+
4
+ /**
5
+ * stacksjs/stacks#2918 — this was duplicated across fourteen dialogs. The
6
+ * cases below are the formatting those copies encoded; they are pinned here
7
+ * so the single definition cannot quietly change what a user reads.
8
+ */
9
+ describe('formErrorMessages', () => {
10
+ it('turns a snake_case field into a sentence-case label', () => {
11
+ expect(formErrorMessages({ first_name: 'is required' })).toEqual(['First name: is required'])
12
+ })
13
+
14
+ it('splits camelCase, but not to the same result', () => {
15
+ // Pinning a wart, not endorsing it. The split inserts a space without
16
+ // lowering the second word, so `firstName` and `first_name` do not read
17
+ // alike. Changing that is a product call; this test exists so the change
18
+ // is deliberate when it happens.
19
+ expect(formErrorMessages({ firstName: 'is required' })).toEqual(['First Name: is required'])
20
+ })
21
+
22
+ it('splits every word, not just the first', () => {
23
+ expect(formErrorMessages({ shipping_zone_name: 'is taken' })).toEqual(['Shipping zone name: is taken'])
24
+ expect(formErrorMessages({ taxRateCode: 'is invalid' })).toEqual(['Tax Rate Code: is invalid'])
25
+ })
26
+
27
+ it('keeps one line per field, in the order given', () => {
28
+ expect(formErrorMessages({ name: 'is required', code: 'is taken' })).toEqual([
29
+ 'Name: is required',
30
+ 'Code: is taken',
31
+ ])
32
+ })
33
+
34
+ it('has nothing to say about an empty map', () => {
35
+ expect(formErrorMessages({})).toEqual([])
36
+ })
37
+
38
+ it('leaves an already-capitalised single word alone', () => {
39
+ expect(formErrorMessages({ Name: 'is required' })).toEqual(['Name: is required'])
40
+ })
41
+ })
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Form helpers shared by the dashboard's editor dialogs.
3
+ *
4
+ * `formErrorMessages` was copied verbatim into fourteen Commerce dialogs
5
+ * (stacksjs/stacks#2918). It is a pure function of a validation-error map with
6
+ * no reactive dependency and no markup, so a component was the wrong home for
7
+ * it: the label formatting it encodes had fourteen definitions, and a change
8
+ * to how a field name becomes a label had to be made in all of them or drift.
9
+ */
10
+
11
+ /**
12
+ * Turn a map of field errors into lines a person can read.
13
+ *
14
+ * Behaviour is the fourteen copies' behaviour, unchanged, including one wart
15
+ * worth naming rather than silently fixing: the two spellings do not read
16
+ * alike. `first_name` becomes "First name", while `firstName` becomes
17
+ * "First Name", because the split inserts a space without lowering the second
18
+ * word. A server reporting snake_case and a client reporting camelCase
19
+ * therefore render differently for the same field.
20
+ *
21
+ * Left as-is here because extracting and changing at once makes a regression
22
+ * impossible to attribute. Fixing it is a one-line change in one place now,
23
+ * which is the point of the extraction.
24
+ */
25
+ export function formErrorMessages(errors: Record<string, string>): string[] {
26
+ return Object.entries(errors).map(([field, message]) => {
27
+ const label = field.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
28
+ return `${label.charAt(0).toUpperCase()}${label.slice(1)}: ${message}`
29
+ })
30
+ }