@happyvertical/smrt-core 0.55.10 → 0.55.12
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.
- package/AGENTS.md +2 -0
- package/agents/build-knowledge.md +40 -0
- package/agents/cookbook-engine.md +102 -0
- package/agents/cookbook.md +74 -0
- package/dist/consumer-plugin/artifact-publication.js +3 -3
- package/dist/consumer-plugin/artifact-publication.js.map +1 -1
- package/dist/consumer-plugin/hosted-sti-collections.d.ts +10 -0
- package/dist/consumer-plugin/hosted-sti-collections.d.ts.map +1 -0
- package/dist/consumer-plugin/hosted-sti-collections.js +96 -0
- package/dist/consumer-plugin/hosted-sti-collections.js.map +1 -0
- package/dist/consumer-plugin/index.d.ts +1 -1
- package/dist/consumer-plugin/index.d.ts.map +1 -1
- package/dist/consumer-plugin/index.js +76 -51
- package/dist/consumer-plugin/index.js.map +1 -1
- package/dist/cookbook/engine/commands.d.ts +37 -0
- package/dist/cookbook/engine/commands.d.ts.map +1 -0
- package/dist/cookbook/engine/commands.js +275 -0
- package/dist/cookbook/engine/commands.js.map +1 -0
- package/dist/cookbook/engine/defaults.d.ts +10 -0
- package/dist/cookbook/engine/defaults.d.ts.map +1 -0
- package/dist/cookbook/engine/defaults.js +49 -0
- package/dist/cookbook/engine/defaults.js.map +1 -0
- package/dist/cookbook/engine/document.d.ts +66 -0
- package/dist/cookbook/engine/document.d.ts.map +1 -0
- package/dist/cookbook/engine/document.js +357 -0
- package/dist/cookbook/engine/document.js.map +1 -0
- package/dist/cookbook/engine/engine.d.ts +49 -0
- package/dist/cookbook/engine/engine.d.ts.map +1 -0
- package/dist/cookbook/engine/engine.js +399 -0
- package/dist/cookbook/engine/engine.js.map +1 -0
- package/dist/cookbook/engine/index.d.ts +14 -0
- package/dist/cookbook/engine/index.d.ts.map +1 -0
- package/dist/cookbook/engine/index.js +8 -0
- package/dist/cookbook/engine/nav.d.ts +45 -0
- package/dist/cookbook/engine/nav.d.ts.map +1 -0
- package/dist/cookbook/engine/nav.js +191 -0
- package/dist/cookbook/engine/nav.js.map +1 -0
- package/dist/cookbook/engine/schemas.d.ts +29 -0
- package/dist/cookbook/engine/schemas.d.ts.map +1 -0
- package/dist/cookbook/engine/schemas.js +363 -0
- package/dist/cookbook/engine/schemas.js.map +1 -0
- package/dist/cookbook/engine/types.d.ts +331 -0
- package/dist/cookbook/engine/types.d.ts.map +1 -0
- package/dist/cookbook/engine/types.js +10 -0
- package/dist/cookbook/engine/types.js.map +1 -0
- package/dist/cookbook/engine.d.ts +2 -0
- package/dist/cookbook/engine.d.ts.map +1 -0
- package/dist/cookbook/engine.js +9 -0
- package/dist/cookbook/index.d.ts +6 -0
- package/dist/cookbook/index.d.ts.map +1 -0
- package/dist/cookbook/v1.schema.json +131 -0
- package/dist/cookbook/validate.d.ts +37 -0
- package/dist/cookbook/validate.d.ts.map +1 -0
- package/dist/cookbook/validate.js +172 -0
- package/dist/cookbook/validate.js.map +1 -0
- package/dist/cookbook.d.ts +7 -0
- package/dist/cookbook.d.ts.map +1 -0
- package/dist/cookbook.js +2 -0
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/prebuild/index.js +13 -13
- package/dist/prebuild/index.js.map +1 -1
- package/dist/scanner/types.d.ts +8 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/smrt-knowledge.json +23 -7
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +4 -1
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
- package/dist/vite-plugin/sync-apply-route.js +1 -0
- package/dist/vite-plugin/sync-apply-route.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts +5 -0
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +1 -1
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/dist/vite-preset/cookbook-exposure.d.ts +11 -0
- package/dist/vite-preset/cookbook-exposure.d.ts.map +1 -0
- package/dist/vite-preset/cookbook-exposure.js +95 -0
- package/dist/vite-preset/cookbook-exposure.js.map +1 -0
- package/dist/vite-preset/index.d.ts +9 -0
- package/dist/vite-preset/index.d.ts.map +1 -1
- package/dist/vite-preset/index.js +17 -7
- package/dist/vite-preset/index.js.map +1 -1
- package/package.json +18 -6
package/AGENTS.md
CHANGED
|
@@ -27,6 +27,8 @@ and repository rules.
|
|
|
27
27
|
| `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |
|
|
28
28
|
| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |
|
|
29
29
|
| `src/recipe.ts`, `../scanner/src/recipes.ts` | `SmrtRecipe`: declared user-facing units (models, nav, `requires`, curation `options`) collected into manifest and knowledge `recipes` (#3590) | [agents/recipes.md](agents/recipes.md) |
|
|
30
|
+
| `src/cookbook/` | Cookbook v1 document: browser-safe validate/migrate, JSON Schema (`./cookbook`, `./cookbook/v1.schema.json`; #3748) | [agents/cookbook.md](agents/cookbook.md) |
|
|
31
|
+
| `src/cookbook/engine/` | Headless cookbook command engine: typed commands, idempotent retries, atomic batches, receipts, tool schemas (`./cookbook/engine`; #3753) | [agents/cookbook-engine.md](agents/cookbook-engine.md) |
|
|
30
32
|
| `src/browser.ts`, `src/host.ts`, `src/host.browser.ts` | Browser entry that builds and evaluates without Node-only modules: host seam, `package.json#browser` swaps, `pnpm test:browser` (#2838) | [agents/browser-entry.md](agents/browser-entry.md) |
|
|
31
33
|
|
|
32
34
|
## Cross-module invariants
|
|
@@ -183,6 +183,46 @@ also dedupes `@sveltejs/kit` (`smrt:shared-runtime`): SvelteKit matches
|
|
|
183
183
|
`redirect()`/`fail()` by class, and a linked/workspace SMRT SvelteKit entry
|
|
184
184
|
would otherwise import its own copy and turn redirects into 500s.
|
|
185
185
|
|
|
186
|
+
**Package models over REST (#3749).** `consumer.packages` registers a package's
|
|
187
|
+
models and gives `@smrt/web` (the consumer's `collectionDefinitions`, one per
|
|
188
|
+
list-capable model, endpoint `/<collection>`) their definitions, but a package
|
|
189
|
+
model gets generated REST routes only when it is on the consumer's hosting
|
|
190
|
+
allowlist (`smrtConsumer({ svelteKit: { objects } })`, #2850: an HTTP exposure
|
|
191
|
+
boundary, each model's own `api` config still applies, so `api: false` and
|
|
192
|
+
empty-include models stay closed, tenancy comes with the generated route). The
|
|
193
|
+
preset fills that allowlist: with `smrt.cookbook.json` present it is the models
|
|
194
|
+
of the cookbook's recipes plus `features`, minus models the cookbook `exposure`
|
|
195
|
+
turns off for `api` or a recipe's `options[model].exposure.api === false`
|
|
196
|
+
(`vite-preset/cookbook-exposure.ts`; recipe `{ exclude }` narrowing is not
|
|
197
|
+
applied by the build yet). `smrt({ expose })` replaces the derived list. No
|
|
198
|
+
cookbook and no `expose` keeps `svelteKit: true` (nothing hosted). The producer's
|
|
199
|
+
`@happyvertical/smrt-virt-web` still covers only the app's own models; read
|
|
200
|
+
package models from `@smrt/web`. `smrt({ objectsDir })` is passed to the hosting
|
|
201
|
+
contribution too: the producer and consumer share one `routesDir`, and the
|
|
202
|
+
generator refuses it when their settings differ.
|
|
203
|
+
|
|
204
|
+
**STI siblings (#3749).** Package STI subclasses share their base's table, and
|
|
205
|
+
the manifest gives them the base's `collection` unless they declare one
|
|
206
|
+
(`consumer-plugin/hosted-sti-collections.ts`). Two hosted siblings would both
|
|
207
|
+
claim `/<base collection>`, so each hosted subclass that inherits its
|
|
208
|
+
collection gets its class-derived one (`Order` -> `orders`,
|
|
209
|
+
`defaultCollectionName` in the scanner), in every manifest view the consumer
|
|
210
|
+
derives: routes, `@smrt/web`, the generated client and registration. This is the
|
|
211
|
+
convention the framework already has for an independent route collection
|
|
212
|
+
(`@smrt({ collection })`), applied by default for hosted subclasses; an explicit
|
|
213
|
+
collection wins. The route therefore reads through the subclass's own
|
|
214
|
+
collection (rows scoped to its `_meta_type`) and the definition for that model
|
|
215
|
+
advertises that route (`objectRef` = the model, `endpoint` = `/orders`). A hosted
|
|
216
|
+
base keeps the shared name and the base-collection semantics (every row of the
|
|
217
|
+
table, any subtype). A subclass left off the allowlist keeps the shared
|
|
218
|
+
collection and folds into the base's definition, as before. A collection class
|
|
219
|
+
that inherits another's item type (`MaterialCollection extends
|
|
220
|
+
ProductCollection`) resolves to the same item and collection, so only one of
|
|
221
|
+
them is hosted (the class named for the item, else the first). Subclasses that
|
|
222
|
+
declare no `api` config get the omitted-config default (full CRUD), not the
|
|
223
|
+
base's `include`; narrow them on the subclass if the base is meant to restrict
|
|
224
|
+
them.
|
|
225
|
+
|
|
186
226
|
```typescript
|
|
187
227
|
// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)
|
|
188
228
|
export default defineConfig({
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Cookbook command engine (#3753)
|
|
2
|
+
|
|
3
|
+
`@happyvertical/smrt-core/cookbook/engine` (`src/cookbook/engine/`) is the one
|
|
4
|
+
place the rules for changing a [cookbook](cookbook.md) live. A planner UI, a
|
|
5
|
+
local Kitchen MCP server, the CLI and a website all call it; each is an adapter.
|
|
6
|
+
It imports only relative modules and `@happyvertical/smrt-types` types, so it
|
|
7
|
+
runs in Node and in a browser. No SvelteKit, storage, singleton or inference.
|
|
8
|
+
|
|
9
|
+
It lives in `smrt-core` because the contract and validator (`./cookbook`) do and
|
|
10
|
+
the package DAG forbids a back edge: types -> core. A separate package would add
|
|
11
|
+
a node for pure functions with no other dependency.
|
|
12
|
+
|
|
13
|
+
## API
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createCookbookEngine } from '@happyvertical/smrt-core/cookbook/engine';
|
|
17
|
+
const engine = createCookbookEngine({ catalog, cookbook?, state? });
|
|
18
|
+
engine.run({ id?, name, input, expectedRevision? }) // CommandResult, never throws
|
|
19
|
+
engine.batch({ id?, commands, expectedRevision? }) // BatchResult, atomic
|
|
20
|
+
engine.snapshot() / .cookbook() / .state() / .subscribe(fn)
|
|
21
|
+
engine.tools / engine.batchTool // JSON Schema tool definitions
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **Catalog is data** (`EngineCatalog`): `recipes` (manifest `RecipeDefinition`
|
|
25
|
+
fits), `models` (`id, name, packageId, exposed?, fields?`), optional
|
|
26
|
+
`cookbooks` (a library), `themePresets`, `currencies`, `settingTargets`.
|
|
27
|
+
Nothing is bundled. Build it from published manifests or `smrt-knowledge.json`.
|
|
28
|
+
- **Commands** (20, `COOKBOOK_COMMANDS_VERSION = 1`): `apply_cookbook` (select),
|
|
29
|
+
`import_cookbook`, `add_cookbook`, `remove_cookbook`, `add_recipes`,
|
|
30
|
+
`remove_recipes`, `add_features`, `remove_features`, `set_settings`,
|
|
31
|
+
`set_policy`, `set_theme`, `reset_theme`, `set_name`, `rename_section`,
|
|
32
|
+
`rename_item`, `hide`, `show`, `validate`, `export_cookbook`, `undo`. Navigation
|
|
33
|
+
and focus are not commands: they are an adapter concern.
|
|
34
|
+
- **Errors** are typed (`CommandError`: `code`, `message`, `path?`, `details?`):
|
|
35
|
+
`unknown_command`, `invalid_input`, `not_found`, `confirmation_required`,
|
|
36
|
+
`conflict`, `id_reuse`, `batch_failed`, `failed`.
|
|
37
|
+
- **Result** carries the `receipt` (`name, id?, summary, changed, revisionBefore,
|
|
38
|
+
revisionAfter, undoId?, changes?, warnings?`), the compact `snapshot` and
|
|
39
|
+
optional `data` (`validate`, `export_cookbook`).
|
|
40
|
+
|
|
41
|
+
## Execution contract
|
|
42
|
+
|
|
43
|
+
- **Revisions** count applied changes (a no-op does not move it), so equal
|
|
44
|
+
histories give equal revisions. `expectedRevision` that is not current fails
|
|
45
|
+
`conflict` with `details.{expectedRevision,currentRevision}`: an older model
|
|
46
|
+
action cannot overwrite a newer manual edit. Send it on every model action.
|
|
47
|
+
- **Command ids**: a repeated `id` with the same command returns the first
|
|
48
|
+
result with `replayed: true` and applies nothing (failures replay too: take a
|
|
49
|
+
new id after re-reading). The same id with a different command is `id_reuse`.
|
|
50
|
+
The journal keeps `maxJournal` (500) ids and is part of `state()`.
|
|
51
|
+
- **Atomic batches** run on a working copy; one failure leaves the state
|
|
52
|
+
byte-identical and returns `batch_failed` naming `details.index`. A batch that
|
|
53
|
+
changes n times advances the revision by n, as n runs would, and has one undo.
|
|
54
|
+
`undo` and per-command ids/revisions are not allowed inside a batch.
|
|
55
|
+
- **Undo** restores the document from before the change; it is a `conflict`
|
|
56
|
+
while the plan differs from what that change left, unless `force`.
|
|
57
|
+
- **Recovery**: `state()` is plain JSON (revision, applied library id, document,
|
|
58
|
+
journal); `createCookbookEngine({ catalog, state })` resumes it, retries
|
|
59
|
+
included. Undo entries are not persisted.
|
|
60
|
+
- **Canonical document**: ids sorted, policies sorted, empty sections absent,
|
|
61
|
+
key order fixed, so two engines export identical text. Recipe/feature ids the
|
|
62
|
+
catalog lacks stay in the document and show as `snapshot.unavailable`.
|
|
63
|
+
|
|
64
|
+
## Deliberately not here
|
|
65
|
+
|
|
66
|
+
Package or starter version pinning (waits on #3604/#3754). Deep overview-widget
|
|
67
|
+
and nav-id checks (smrt-svelte owns them; `import_cookbook` carries `overviews`
|
|
68
|
+
and `layout` through unjudged, `validate` only flags layout ids the menu lacks).
|
|
69
|
+
Typed diagnostics from `validateCookbook` itself (#3754): `validate` wraps its
|
|
70
|
+
strings as `invalid_document` until it returns them.
|
|
71
|
+
|
|
72
|
+
## Becoming an adapter: what smrt-planner changes (after the smrt release)
|
|
73
|
+
|
|
74
|
+
Parity to hold: the planner's `tests/headless-planner.test.ts` scenario is
|
|
75
|
+
ported in `__tests__/engine.test.ts` (`SCRIPT`) over a data catalog.
|
|
76
|
+
|
|
77
|
+
1. Depend on the released `@happyvertical/smrt-core`; build `EngineCatalog` from
|
|
78
|
+
its catalog data (`recipes.json`/`catalog.json` -> `recipes`, `models`,
|
|
79
|
+
`cookbooks`: the library wrapper keeps id/icon/keywords planner-side).
|
|
80
|
+
2. Delete `commands/execute.ts`, `commands/runner.ts`, `commands/snapshot.ts`,
|
|
81
|
+
`commands/schemas.ts` (keep `checkSchema` only if still used), `recipes/
|
|
82
|
+
plan-data.ts`, `recipes/resolve.ts`, `settings/app-settings.ts` rules,
|
|
83
|
+
`assistant/change.ts` apply* helpers, `cookbook/assemble.ts` and the
|
|
84
|
+
`createPlainHost`. `createHeadlessPlanner` becomes `createCookbookEngine`
|
|
85
|
+
behind the same `run/snapshot/subscribe/cookbook` names.
|
|
86
|
+
3. The browser controller keeps: `focus` and the planner tab/section cells
|
|
87
|
+
(snapshot `focus` is added by the adapter), reactive wrapping of
|
|
88
|
+
`engine.subscribe`, `onReplaced` (regenerate sample data after
|
|
89
|
+
`apply_cookbook`), and the `cookbookStore` <-> `engine.state()` sync
|
|
90
|
+
(`engine.cookbook()` to persist; rebuild with `cookbook`/`state` on load).
|
|
91
|
+
4. Command differences to absorb: `apply_cookbook`/`import_cookbook` report
|
|
92
|
+
`changed` by content; `rename_*`/`hide`/`show` need the catalog `models`
|
|
93
|
+
`packageId` for item ids; undo is whole-document (the old per-slice undo goes);
|
|
94
|
+
`unsupported` is gone (the engine always has every slice); new `set_name`,
|
|
95
|
+
`validate`; receipts gain `revisionBefore/After`; `expectedRevision` moves
|
|
96
|
+
into the command (the option form still works).
|
|
97
|
+
5. Overview customisation stays a planner/smrt-svelte concern; hold `overviews`
|
|
98
|
+
in the document via `import_cookbook` and write them with a planner-side
|
|
99
|
+
command until an engine command is agreed.
|
|
100
|
+
6. MCP/Kitchen (#3752, done): `smrt kitchen mcp` holds one engine per plan id and
|
|
101
|
+
serves `engine.batchTool`'s schema as `run_commands`; the plan reaches disk
|
|
102
|
+
as `smrt.cookbook.json` through `cookbook apply` (`cli/agents/kitchen.md`).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Cookbook v1 contract (#3747, #3748)
|
|
2
|
+
|
|
3
|
+
A cookbook is the portable JSON document that says what an app is made of.
|
|
4
|
+
Defined once here so the CLI (`smrt cookbook validate|apply`) and the cookbook
|
|
5
|
+
app shell read the same thing.
|
|
6
|
+
|
|
7
|
+
| Piece | Where |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Types (type-only) | `@happyvertical/smrt-types`: `Cookbook`, `CookbookPolicyRow`, `CookbookLayout`, `CookbookTheme`, `CookbookOverviewOverride`, `CookbookExposureSurface` |
|
|
10
|
+
| Parse / validate / migrate (browser-safe) | `@happyvertical/smrt-core/cookbook`: `validateCookbook(doc, { recipes? })`, `parseCookbookText`, `COOKBOOK_SCHEMA_URL`, `COOKBOOK_LEGACY_SCHEMA_URL`, `COOKBOOK_VERSION` |
|
|
11
|
+
| JSON Schema | `@happyvertical/smrt-core/cookbook/v1.schema.json` (`$id` is the `$schema` URL; s-m-r-t.dev hosts it later) |
|
|
12
|
+
| App shell | `@happyvertical/smrt-svelte/cookbook` renders a cookbook; `loadCookbookApp` in `/cookbook/server` loads and validates it ([cookbook-shell.md](../../smrt-svelte/agents/cookbook-shell.md)) |
|
|
13
|
+
| Fixtures | `src/cookbook/__fixtures__/` (the planner's bakery, mechanic, welder, yoga-studio documents) |
|
|
14
|
+
|
|
15
|
+
Fields: `$schema`, `version: 1`, optional `name`/`description`, `recipes`,
|
|
16
|
+
`features` (absent reads as `[]`), `policies` (app-scope smrt-fields rows),
|
|
17
|
+
`exposure`, `layout` (smrt-svelte `ShellLayout`), `theme`, `overviews`
|
|
18
|
+
(smrt-svelte `OverviewOverride` per overview id). Sample data is not part of
|
|
19
|
+
the document; the planner's library wrapper (id, icon, keywords, settings) is
|
|
20
|
+
planner-side.
|
|
21
|
+
|
|
22
|
+
## Rules
|
|
23
|
+
|
|
24
|
+
- Versioning: add optional fields within `version: 1`; readers keep and ignore
|
|
25
|
+
unknown keys (`additionalProperties: true`). A breaking change is `version: 2`.
|
|
26
|
+
- Layout and overview values are typed structurally so smrt-types and core never
|
|
27
|
+
depend on smrt-svelte; smrt-svelte owns deep validation (widgets against page
|
|
28
|
+
definitions, nav ids). `CookbookLayout`/`CookbookOverviewOverride` are
|
|
29
|
+
identical to the smrt-svelte types; a typechecked assertion in smrt-svelte
|
|
30
|
+
(`cookbook/__tests__/contract-types.ts`) fails when either side adds a field.
|
|
31
|
+
- Legacy `.../blueprint/v1.json` is accepted on read and rewritten; never written.
|
|
32
|
+
- Recipe existence, `requires`/`requiresAny` and setting targets need manifests:
|
|
33
|
+
`validateCookbook(doc, { recipes })` checks ids when a catalog is passed;
|
|
34
|
+
the rest is the CLI's job.
|
|
35
|
+
- Keep the JSON Schema and `validate.ts` in step; the test validates the same
|
|
36
|
+
fixtures against both.
|
|
37
|
+
|
|
38
|
+
## Applying a cookbook
|
|
39
|
+
|
|
40
|
+
`smrt cookbook validate <file|url>` and `smrt cookbook apply <file|url> [dir]`
|
|
41
|
+
(`packages/cli/agents/cookbook-apply.md`) are the consumers. Apply writes
|
|
42
|
+
`smrt.cookbook.json` at the project root, plus the packages the recipes,
|
|
43
|
+
features and policies need, into `package.json`. The file is the cookbook
|
|
44
|
+
verbatim (legacy `$schema` rewritten); apps read it, apply does not interpret it.
|
|
45
|
+
`policies` is required by the validator (use `[]`). The cookbook carries no package
|
|
46
|
+
versions, so apply uses the project's smrt line.
|
|
47
|
+
|
|
48
|
+
## Hosting package models
|
|
49
|
+
|
|
50
|
+
An app that applies a cookbook lists the recipes' packages in `consumer.packages`
|
|
51
|
+
and nothing else: the `smrt()` Vite preset derives the REST allowlist from the
|
|
52
|
+
cookbook, so the Orders of a `commerce.sales` recipe get `/api/orders` routes and
|
|
53
|
+
an `@smrt/web` definition (see [build-knowledge.md](build-knowledge.md), "Package
|
|
54
|
+
models over REST"). `exposure` listing `api` for a model withdraws its route. Models that share an
|
|
55
|
+
STI table (the bakery's `Order`, `WholesaleOrder`, `PurchaseOrder` and
|
|
56
|
+
`ProductionOrder`; `Product` and `Material`) each get their own route and
|
|
57
|
+
definition, at `/<class collection>` (`/orders`, `/productionorders`), listing
|
|
58
|
+
only their own rows; the build-knowledge section explains why.
|
|
59
|
+
|
|
60
|
+
Permissions do not follow the route. The permission catalog grants one namespace
|
|
61
|
+
per STI table, the base's (`contracts.create`; there is no `orders.create`), so a
|
|
62
|
+
hosted subclass moved to its own route collection keeps authorizing against the
|
|
63
|
+
base's slugs: the hosting rewrite records the original collection as
|
|
64
|
+
`permissionCollection`, which the generated route guard and `sync/apply` read
|
|
65
|
+
before the (rewritten) route collection. It matches the fail-closed `api`
|
|
66
|
+
inheritance and the CLI/MCP operation guards, which already derive the base's
|
|
67
|
+
slug. No role is widened: a caller needs exactly the grants the base requires.
|
|
68
|
+
Reads need no operation slug. A subclass that declares its own `collection` has
|
|
69
|
+
its own namespace and is untouched.
|
|
70
|
+
|
|
71
|
+
## Editing a cookbook
|
|
72
|
+
|
|
73
|
+
The command engine (`@happyvertical/smrt-core/cookbook/engine`) is the one place
|
|
74
|
+
that changes a cookbook: see [cookbook-engine.md](cookbook-engine.md).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import * as fs from "node:fs";
|
|
2
|
+
import * as fs$1 from "node:fs";
|
|
3
3
|
//#region src/consumer-plugin/artifact-publication.ts
|
|
4
4
|
/**
|
|
5
5
|
* Stage and publish a related artifact set with synchronous-failure recovery.
|
|
@@ -13,7 +13,7 @@ import * as fs from "node:fs";
|
|
|
13
13
|
* There is no portable multi-file filesystem transaction: a process crash or a
|
|
14
14
|
* concurrent reader between individual renames can still observe a mixed set.
|
|
15
15
|
*/
|
|
16
|
-
function publishArtifactFiles(files, filesystem = fs) {
|
|
16
|
+
function publishArtifactFiles(files, filesystem = fs$1) {
|
|
17
17
|
const unique = `${process.pid}-${Date.now()}`;
|
|
18
18
|
const staged = [];
|
|
19
19
|
const backed = [];
|
|
@@ -76,7 +76,7 @@ function publishArtifactFiles(files, filesystem = fs) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
/** Atomically replace one artifact without ever removing its reader-visible path. */
|
|
79
|
-
function publishAtomicArtifact(file, filesystem = fs) {
|
|
79
|
+
function publishAtomicArtifact(file, filesystem = fs$1) {
|
|
80
80
|
const stagedPath = `${file.path}.smrt-${randomUUID()}.tmp`;
|
|
81
81
|
const stats = filesystem.existsSync(file.path) ? filesystem.statSync(file.path) : void 0;
|
|
82
82
|
if (stats && !stats.isFile()) throw new Error(`Artifact target is not a file: ${file.path}`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"artifact-publication.js","names":[],"sources":["../../src/consumer-plugin/artifact-publication.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport * as fs from 'node:fs';\n\nexport interface ArtifactFile {\n path: string;\n content: string;\n}\n\nexport interface ArtifactFilesystem {\n existsSync: typeof fs.existsSync;\n renameSync: typeof fs.renameSync;\n statSync: typeof fs.statSync;\n unlinkSync: typeof fs.unlinkSync;\n writeFileSync: typeof fs.writeFileSync;\n chmodSync: typeof fs.chmodSync;\n}\n\ninterface StagedArtifact {\n path: string;\n stagedPath: string;\n backupPath?: string;\n}\n\n/**\n * Stage and publish a related artifact set with synchronous-failure recovery.\n *\n * Every existing target is first renamed to an adjacent backup, preserving its\n * mode, before a staged replacement is renamed into place. If a later rename\n * fails, rollback restores backups using rename only; it never truncates an\n * old target by rewriting it. A rollback failure retains its backup for manual\n * recovery and is included with the original publication failure.\n *\n * There is no portable multi-file filesystem transaction: a process crash or a\n * concurrent reader between individual renames can still observe a mixed set.\n */\nexport function publishArtifactFiles(\n files: ArtifactFile[],\n filesystem: ArtifactFilesystem = fs,\n): void {\n const unique = `${process.pid}-${Date.now()}`;\n const staged: StagedArtifact[] = [];\n const backed: StagedArtifact[] = [];\n const published: StagedArtifact[] = [];\n let retainBackups = false;\n\n try {\n for (const [index, file] of files.entries()) {\n const exists = filesystem.existsSync(file.path);\n const stats = exists ? filesystem.statSync(file.path) : undefined;\n if (stats && !stats.isFile()) {\n throw new Error(`Artifact target is not a file: ${file.path}`);\n }\n const mode = stats ? stats.mode & 0o777 : undefined;\n const stagedFile = {\n path: file.path,\n stagedPath: `${file.path}.smrt-${unique}-${index}.tmp`,\n backupPath: exists\n ? `${file.path}.smrt-${unique}-${index}.bak`\n : undefined,\n };\n // Register before the write: a failed write can still leave a partial\n // temporary file, which the finally cleanup must see.\n staged.push(stagedFile);\n filesystem.writeFileSync(stagedFile.stagedPath, file.content, 'utf-8');\n if (mode !== undefined) filesystem.chmodSync(stagedFile.stagedPath, mode);\n }\n for (const file of staged) {\n if (!file.backupPath) continue;\n filesystem.renameSync(file.path, file.backupPath);\n backed.push(file);\n }\n for (const file of staged) {\n filesystem.renameSync(file.stagedPath, file.path);\n published.push(file);\n }\n } catch (publicationError) {\n const rollbackErrors: unknown[] = [];\n for (const file of backed.reverse()) {\n try {\n if (!file.backupPath) {\n throw new Error(`Missing artifact backup: ${file.path}`);\n }\n // Replacing a published staged file is safe: its authoritative prior\n // contents are held by backupPath until this rename succeeds.\n filesystem.renameSync(file.backupPath, file.path);\n } catch (rollbackError) {\n rollbackErrors.push(rollbackError);\n }\n }\n for (const file of published) {\n if (file.backupPath) continue;\n try {\n if (filesystem.existsSync(file.path)) filesystem.unlinkSync(file.path);\n } catch (rollbackError) {\n rollbackErrors.push(rollbackError);\n }\n }\n if (rollbackErrors.length > 0) {\n retainBackups = true;\n throw new AggregateError(\n [publicationError, ...rollbackErrors],\n 'Artifact publication failed and rollback retained recovery backups',\n );\n }\n throw publicationError;\n } finally {\n for (const file of staged) {\n try {\n if (filesystem.existsSync(file.stagedPath))\n filesystem.unlinkSync(file.stagedPath);\n } catch {\n // A staging file is not an authoritative artifact.\n }\n if (!retainBackups) {\n try {\n if (file.backupPath && filesystem.existsSync(file.backupPath)) {\n filesystem.unlinkSync(file.backupPath);\n }\n } catch {\n // A failed cleanup leaves a recoverable backup behind.\n }\n }\n }\n }\n}\n\n/** Atomically replace one artifact without ever removing its reader-visible path. */\nexport function publishAtomicArtifact(\n file: ArtifactFile,\n filesystem: ArtifactFilesystem = fs,\n): void {\n const stagedPath = `${file.path}.smrt-${randomUUID()}.tmp`;\n const exists = filesystem.existsSync(file.path);\n const stats = exists ? filesystem.statSync(file.path) : undefined;\n if (stats && !stats.isFile()) {\n throw new Error(`Artifact target is not a file: ${file.path}`);\n }\n try {\n filesystem.writeFileSync(stagedPath, file.content, 'utf-8');\n if (stats) filesystem.chmodSync(stagedPath, stats.mode & 0o777);\n // POSIX rename replaces the destination atomically: readers see either\n // the old complete file or the new complete file, never no path/bytes.\n filesystem.renameSync(stagedPath, file.path);\n } finally {\n if (filesystem.existsSync(stagedPath)) filesystem.unlinkSync(stagedPath);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAmCA,SAAgB,qBACd,OACA,aAAiC,
|
|
1
|
+
{"version":3,"file":"artifact-publication.js","names":[],"sources":["../../src/consumer-plugin/artifact-publication.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport * as fs from 'node:fs';\n\nexport interface ArtifactFile {\n path: string;\n content: string;\n}\n\nexport interface ArtifactFilesystem {\n existsSync: typeof fs.existsSync;\n renameSync: typeof fs.renameSync;\n statSync: typeof fs.statSync;\n unlinkSync: typeof fs.unlinkSync;\n writeFileSync: typeof fs.writeFileSync;\n chmodSync: typeof fs.chmodSync;\n}\n\ninterface StagedArtifact {\n path: string;\n stagedPath: string;\n backupPath?: string;\n}\n\n/**\n * Stage and publish a related artifact set with synchronous-failure recovery.\n *\n * Every existing target is first renamed to an adjacent backup, preserving its\n * mode, before a staged replacement is renamed into place. If a later rename\n * fails, rollback restores backups using rename only; it never truncates an\n * old target by rewriting it. A rollback failure retains its backup for manual\n * recovery and is included with the original publication failure.\n *\n * There is no portable multi-file filesystem transaction: a process crash or a\n * concurrent reader between individual renames can still observe a mixed set.\n */\nexport function publishArtifactFiles(\n files: ArtifactFile[],\n filesystem: ArtifactFilesystem = fs,\n): void {\n const unique = `${process.pid}-${Date.now()}`;\n const staged: StagedArtifact[] = [];\n const backed: StagedArtifact[] = [];\n const published: StagedArtifact[] = [];\n let retainBackups = false;\n\n try {\n for (const [index, file] of files.entries()) {\n const exists = filesystem.existsSync(file.path);\n const stats = exists ? filesystem.statSync(file.path) : undefined;\n if (stats && !stats.isFile()) {\n throw new Error(`Artifact target is not a file: ${file.path}`);\n }\n const mode = stats ? stats.mode & 0o777 : undefined;\n const stagedFile = {\n path: file.path,\n stagedPath: `${file.path}.smrt-${unique}-${index}.tmp`,\n backupPath: exists\n ? `${file.path}.smrt-${unique}-${index}.bak`\n : undefined,\n };\n // Register before the write: a failed write can still leave a partial\n // temporary file, which the finally cleanup must see.\n staged.push(stagedFile);\n filesystem.writeFileSync(stagedFile.stagedPath, file.content, 'utf-8');\n if (mode !== undefined) filesystem.chmodSync(stagedFile.stagedPath, mode);\n }\n for (const file of staged) {\n if (!file.backupPath) continue;\n filesystem.renameSync(file.path, file.backupPath);\n backed.push(file);\n }\n for (const file of staged) {\n filesystem.renameSync(file.stagedPath, file.path);\n published.push(file);\n }\n } catch (publicationError) {\n const rollbackErrors: unknown[] = [];\n for (const file of backed.reverse()) {\n try {\n if (!file.backupPath) {\n throw new Error(`Missing artifact backup: ${file.path}`);\n }\n // Replacing a published staged file is safe: its authoritative prior\n // contents are held by backupPath until this rename succeeds.\n filesystem.renameSync(file.backupPath, file.path);\n } catch (rollbackError) {\n rollbackErrors.push(rollbackError);\n }\n }\n for (const file of published) {\n if (file.backupPath) continue;\n try {\n if (filesystem.existsSync(file.path)) filesystem.unlinkSync(file.path);\n } catch (rollbackError) {\n rollbackErrors.push(rollbackError);\n }\n }\n if (rollbackErrors.length > 0) {\n retainBackups = true;\n throw new AggregateError(\n [publicationError, ...rollbackErrors],\n 'Artifact publication failed and rollback retained recovery backups',\n );\n }\n throw publicationError;\n } finally {\n for (const file of staged) {\n try {\n if (filesystem.existsSync(file.stagedPath))\n filesystem.unlinkSync(file.stagedPath);\n } catch {\n // A staging file is not an authoritative artifact.\n }\n if (!retainBackups) {\n try {\n if (file.backupPath && filesystem.existsSync(file.backupPath)) {\n filesystem.unlinkSync(file.backupPath);\n }\n } catch {\n // A failed cleanup leaves a recoverable backup behind.\n }\n }\n }\n }\n}\n\n/** Atomically replace one artifact without ever removing its reader-visible path. */\nexport function publishAtomicArtifact(\n file: ArtifactFile,\n filesystem: ArtifactFilesystem = fs,\n): void {\n const stagedPath = `${file.path}.smrt-${randomUUID()}.tmp`;\n const exists = filesystem.existsSync(file.path);\n const stats = exists ? filesystem.statSync(file.path) : undefined;\n if (stats && !stats.isFile()) {\n throw new Error(`Artifact target is not a file: ${file.path}`);\n }\n try {\n filesystem.writeFileSync(stagedPath, file.content, 'utf-8');\n if (stats) filesystem.chmodSync(stagedPath, stats.mode & 0o777);\n // POSIX rename replaces the destination atomically: readers see either\n // the old complete file or the new complete file, never no path/bytes.\n filesystem.renameSync(stagedPath, file.path);\n } finally {\n if (filesystem.existsSync(stagedPath)) filesystem.unlinkSync(stagedPath);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAmCA,SAAgB,qBACd,OACA,aAAiC,MAC3B;CACN,MAAM,SAAS,GAAG,QAAQ,IAAI,GAAG,KAAK,IAAI;CAC1C,MAAM,SAA2B,CAAC;CAClC,MAAM,SAA2B,CAAC;CAClC,MAAM,YAA8B,CAAC;CACrC,IAAI,gBAAgB;CAEpB,IAAI;EACF,KAAK,MAAM,CAAC,OAAO,SAAS,MAAM,QAAQ,GAAG;GAC3C,MAAM,SAAS,WAAW,WAAW,KAAK,IAAI;GAC9C,MAAM,QAAQ,SAAS,WAAW,SAAS,KAAK,IAAI,IAAI,KAAA;GACxD,IAAI,SAAS,CAAC,MAAM,OAAO,GACzB,MAAM,IAAI,MAAM,kCAAkC,KAAK,MAAM;GAE/D,MAAM,OAAO,QAAQ,MAAM,OAAO,MAAQ,KAAA;GAC1C,MAAM,aAAa;IACjB,MAAM,KAAK;IACX,YAAY,GAAG,KAAK,KAAK,QAAQ,OAAO,GAAG,MAAM;IACjD,YAAY,SACR,GAAG,KAAK,KAAK,QAAQ,OAAO,GAAG,MAAM,QACrC,KAAA;GACN;GAGA,OAAO,KAAK,UAAU;GACtB,WAAW,cAAc,WAAW,YAAY,KAAK,SAAS,OAAO;GACrE,IAAI,SAAS,KAAA,GAAW,WAAW,UAAU,WAAW,YAAY,IAAI;EAC1E;EACA,KAAK,MAAM,QAAQ,QAAQ;GACzB,IAAI,CAAC,KAAK,YAAY;GACtB,WAAW,WAAW,KAAK,MAAM,KAAK,UAAU;GAChD,OAAO,KAAK,IAAI;EAClB;EACA,KAAK,MAAM,QAAQ,QAAQ;GACzB,WAAW,WAAW,KAAK,YAAY,KAAK,IAAI;GAChD,UAAU,KAAK,IAAI;EACrB;CACF,SAAS,kBAAkB;EACzB,MAAM,iBAA4B,CAAC;EACnC,KAAK,MAAM,QAAQ,OAAO,QAAQ,GAChC,IAAI;GACF,IAAI,CAAC,KAAK,YACR,MAAM,IAAI,MAAM,4BAA4B,KAAK,MAAM;GAIzD,WAAW,WAAW,KAAK,YAAY,KAAK,IAAI;EAClD,SAAS,eAAe;GACtB,eAAe,KAAK,aAAa;EACnC;EAEF,KAAK,MAAM,QAAQ,WAAW;GAC5B,IAAI,KAAK,YAAY;GACrB,IAAI;IACF,IAAI,WAAW,WAAW,KAAK,IAAI,GAAG,WAAW,WAAW,KAAK,IAAI;GACvE,SAAS,eAAe;IACtB,eAAe,KAAK,aAAa;GACnC;EACF;EACA,IAAI,eAAe,SAAS,GAAG;GAC7B,gBAAgB;GAChB,MAAM,IAAI,eACR,CAAC,kBAAkB,GAAG,cAAc,GACpC,oEACF;EACF;EACA,MAAM;CACR,UAAU;EACR,KAAK,MAAM,QAAQ,QAAQ;GACzB,IAAI;IACF,IAAI,WAAW,WAAW,KAAK,UAAU,GACvC,WAAW,WAAW,KAAK,UAAU;GACzC,QAAQ,CAER;GACA,IAAI,CAAC,eACH,IAAI;IACF,IAAI,KAAK,cAAc,WAAW,WAAW,KAAK,UAAU,GAC1D,WAAW,WAAW,KAAK,UAAU;GAEzC,QAAQ,CAER;EAEJ;CACF;AACF;;AAGA,SAAgB,sBACd,MACA,aAAiC,MAC3B;CACN,MAAM,aAAa,GAAG,KAAK,KAAK,QAAQ,WAAW,EAAE;CAErD,MAAM,QADS,WAAW,WAAW,KAAK,IAC5B,IAAS,WAAW,SAAS,KAAK,IAAI,IAAI,KAAA;CACxD,IAAI,SAAS,CAAC,MAAM,OAAO,GACzB,MAAM,IAAI,MAAM,kCAAkC,KAAK,MAAM;CAE/D,IAAI;EACF,WAAW,cAAc,YAAY,KAAK,SAAS,OAAO;EAC1D,IAAI,OAAO,WAAW,UAAU,YAAY,MAAM,OAAO,GAAK;EAG9D,WAAW,WAAW,YAAY,KAAK,IAAI;CAC7C,UAAU;EACR,IAAI,WAAW,WAAW,UAAU,GAAG,WAAW,WAAW,UAAU;CACzE;AACF"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SmartObjectDefinition } from '../scanner/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* @param manifest - The aggregated manifest of every consumed package.
|
|
4
|
+
* @param hosted - Provider-qualified refs the consumer hosts.
|
|
5
|
+
* @param refOf - The qualified ref of a manifest entry.
|
|
6
|
+
* @returns The manifest with each hosted, collection-inheriting STI subclass
|
|
7
|
+
* moved to its own collection; `manifest` itself when none needs it.
|
|
8
|
+
*/
|
|
9
|
+
export declare function separateHostedStiCollections<M extends object>(manifest: M, hosted: readonly string[], refOf: (key: string, def: SmartObjectDefinition) => string | undefined): M;
|
|
10
|
+
//# sourceMappingURL=hosted-sti-collections.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hosted-sti-collections.d.ts","sourceRoot":"","sources":["../../src/consumer-plugin/hosted-sti-collections.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EACV,qBAAqB,EAEtB,MAAM,qBAAqB,CAAC;AAyC7B;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,CAAC,SAAS,MAAM,EAC3D,QAAQ,EAAE,CAAC,EACX,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,qBAAqB,KAAK,MAAM,GAAG,SAAS,GACrE,CAAC,CAqDH"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { findManifestObjectByName, isCollectionManifestClass, isStiChildModel, resolveCollectionItemObject } from "../vite-plugin/web-collections.js";
|
|
2
|
+
import { defaultCollectionName } from "@happyvertical/smrt-scanner";
|
|
3
|
+
//#region src/consumer-plugin/hosted-sti-collections.ts
|
|
4
|
+
/**
|
|
5
|
+
* Own route collections for hosted STI subclasses (#3749).
|
|
6
|
+
*
|
|
7
|
+
* An STI subclass shares its base's table, and the manifest generator also
|
|
8
|
+
* gives it the base's `collection` unless it declares one
|
|
9
|
+
* (`@smrt({ collection })`, "collection controls routes/permissions
|
|
10
|
+
* independently of shared storage"). Hosting `Order` and `ProductionOrder`
|
|
11
|
+
* beside each other would therefore claim `/contracts` twice, and one handler
|
|
12
|
+
* would silently shadow the other.
|
|
13
|
+
*
|
|
14
|
+
* A hosted subclass that has not declared a collection gets its class-derived
|
|
15
|
+
* one (`orders`), the name it would have had as a plain model. Its generated
|
|
16
|
+
* route then lists and reads through its own collection, which already scopes
|
|
17
|
+
* every read to the subclass `_meta_type`; permissions do not move with it (the
|
|
18
|
+
* catalog grants the STI base's `contracts.*` slugs only, #3770), so the moved
|
|
19
|
+
* definition records the original collection as `permissionCollection`; the base keeps the shared name and
|
|
20
|
+
* the base-collection semantics (every row of the table). The same rewritten
|
|
21
|
+
* manifest feeds the `@smrt/web` definitions and the generated client, so each
|
|
22
|
+
* hosted class advertises exactly the endpoint its route serves.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The `api` config of the nearest STI ancestor that declares one. An omitted
|
|
26
|
+
* `api` means full CRUD, so a hosted subclass that says nothing would expose
|
|
27
|
+
* `DELETE /orders/:id` even though its base (`Contract`) allows only
|
|
28
|
+
* list/get/create/update. Hosting fails closed: the subclass inherits the base's
|
|
29
|
+
* exposure unless it declares its own (hosting path only; the framework-wide
|
|
30
|
+
* default is unchanged).
|
|
31
|
+
*/
|
|
32
|
+
function inheritedApi(manifest, obj) {
|
|
33
|
+
const seen = /* @__PURE__ */ new Set();
|
|
34
|
+
let child = obj;
|
|
35
|
+
let parentName = child.extendsQualified || child.extends;
|
|
36
|
+
while (parentName && !seen.has(parentName)) {
|
|
37
|
+
seen.add(parentName);
|
|
38
|
+
const parent = findManifestObjectByName(manifest, parentName, child);
|
|
39
|
+
if (!parent) return void 0;
|
|
40
|
+
if (!(parent.collection === obj.collection || parent.decoratorConfig?.tableStrategy === "sti")) return void 0;
|
|
41
|
+
const api = parent.decoratorConfig?.api;
|
|
42
|
+
if (api !== void 0) return api;
|
|
43
|
+
child = parent;
|
|
44
|
+
parentName = parent.extendsQualified || parent.extends;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* @param manifest - The aggregated manifest of every consumed package.
|
|
49
|
+
* @param hosted - Provider-qualified refs the consumer hosts.
|
|
50
|
+
* @param refOf - The qualified ref of a manifest entry.
|
|
51
|
+
* @returns The manifest with each hosted, collection-inheriting STI subclass
|
|
52
|
+
* moved to its own collection; `manifest` itself when none needs it.
|
|
53
|
+
*/
|
|
54
|
+
function separateHostedStiCollections(manifest, hosted, refOf) {
|
|
55
|
+
const source = manifest;
|
|
56
|
+
const wanted = new Set(hosted);
|
|
57
|
+
let objects;
|
|
58
|
+
const moved = /* @__PURE__ */ new Map();
|
|
59
|
+
for (const [key, def] of Object.entries(source.objects)) {
|
|
60
|
+
const ref = refOf(key, def);
|
|
61
|
+
if (!ref || !wanted.has(ref)) continue;
|
|
62
|
+
const collection = !def.decoratorConfig?.collection && isStiChildModel(source, def) ? defaultCollectionName(def.className) : def.collection;
|
|
63
|
+
const api = def.decoratorConfig?.api === void 0 ? inheritedApi(source, def) : void 0;
|
|
64
|
+
if (collection === def.collection && api === void 0) continue;
|
|
65
|
+
if (collection !== def.collection) moved.set(def, collection);
|
|
66
|
+
objects ??= { ...source.objects };
|
|
67
|
+
objects[key] = {
|
|
68
|
+
...def,
|
|
69
|
+
collection,
|
|
70
|
+
...collection === def.collection ? {} : { permissionCollection: def.collection },
|
|
71
|
+
...api === void 0 ? {} : { decoratorConfig: {
|
|
72
|
+
...def.decoratorConfig,
|
|
73
|
+
api
|
|
74
|
+
} }
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (!objects) return manifest;
|
|
78
|
+
for (const [key, def] of Object.entries(source.objects)) {
|
|
79
|
+
if (!isCollectionManifestClass(source, def)) continue;
|
|
80
|
+
const item = resolveCollectionItemObject(source, def);
|
|
81
|
+
const collection = item ? moved.get(item) : void 0;
|
|
82
|
+
if (collection && def.collection !== collection) objects[key] = {
|
|
83
|
+
...def,
|
|
84
|
+
collection,
|
|
85
|
+
permissionCollection: def.collection
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
...manifest,
|
|
90
|
+
objects
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
export { separateHostedStiCollections };
|
|
95
|
+
|
|
96
|
+
//# sourceMappingURL=hosted-sti-collections.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hosted-sti-collections.js","names":[],"sources":["../../src/consumer-plugin/hosted-sti-collections.ts"],"sourcesContent":["/**\n * Own route collections for hosted STI subclasses (#3749).\n *\n * An STI subclass shares its base's table, and the manifest generator also\n * gives it the base's `collection` unless it declares one\n * (`@smrt({ collection })`, \"collection controls routes/permissions\n * independently of shared storage\"). Hosting `Order` and `ProductionOrder`\n * beside each other would therefore claim `/contracts` twice, and one handler\n * would silently shadow the other.\n *\n * A hosted subclass that has not declared a collection gets its class-derived\n * one (`orders`), the name it would have had as a plain model. Its generated\n * route then lists and reads through its own collection, which already scopes\n * every read to the subclass `_meta_type`; permissions do not move with it (the\n * catalog grants the STI base's `contracts.*` slugs only, #3770), so the moved\n * definition records the original collection as `permissionCollection`; the base keeps the shared name and\n * the base-collection semantics (every row of the table). The same rewritten\n * manifest feeds the `@smrt/web` definitions and the generated client, so each\n * hosted class advertises exactly the endpoint its route serves.\n */\nimport { defaultCollectionName } from '@happyvertical/smrt-scanner';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types.js';\nimport {\n findManifestObjectByName,\n isCollectionManifestClass,\n isStiChildModel,\n resolveCollectionItemObject,\n} from '../vite-plugin/web-collections.js';\n\n/**\n * The `api` config of the nearest STI ancestor that declares one. An omitted\n * `api` means full CRUD, so a hosted subclass that says nothing would expose\n * `DELETE /orders/:id` even though its base (`Contract`) allows only\n * list/get/create/update. Hosting fails closed: the subclass inherits the base's\n * exposure unless it declares its own (hosting path only; the framework-wide\n * default is unchanged).\n */\nfunction inheritedApi(\n manifest: SmartObjectManifest,\n obj: SmartObjectDefinition,\n): unknown {\n const seen = new Set<string>();\n let child = obj;\n let parentName = child.extendsQualified || child.extends;\n while (parentName && !seen.has(parentName)) {\n seen.add(parentName);\n const parent = findManifestObjectByName(manifest, parentName, child);\n if (!parent) return undefined;\n // Only an STI relative lends its exposure: it shares the table (and so the\n // inherited collection) or is the declared STI base.\n const sti =\n parent.collection === obj.collection ||\n parent.decoratorConfig?.tableStrategy === 'sti';\n if (!sti) return undefined;\n const api = parent.decoratorConfig?.api;\n if (api !== undefined) return api;\n child = parent;\n parentName = parent.extendsQualified || parent.extends;\n }\n return undefined;\n}\n\n/**\n * @param manifest - The aggregated manifest of every consumed package.\n * @param hosted - Provider-qualified refs the consumer hosts.\n * @param refOf - The qualified ref of a manifest entry.\n * @returns The manifest with each hosted, collection-inheriting STI subclass\n * moved to its own collection; `manifest` itself when none needs it.\n */\nexport function separateHostedStiCollections<M extends object>(\n manifest: M,\n hosted: readonly string[],\n refOf: (key: string, def: SmartObjectDefinition) => string | undefined,\n): M {\n const source = manifest as unknown as SmartObjectManifest;\n const wanted = new Set(hosted);\n let objects: SmartObjectManifest['objects'] | undefined;\n const moved = new Map<SmartObjectDefinition, string>();\n for (const [key, def] of Object.entries(source.objects)) {\n const ref = refOf(key, def);\n if (!ref || !wanted.has(ref)) continue;\n // An explicit `collection` is already independent; only an inherited one\n // collides.\n const separable =\n !def.decoratorConfig?.collection && isStiChildModel(source, def);\n const collection = separable\n ? defaultCollectionName(def.className)\n : def.collection;\n const api =\n def.decoratorConfig?.api === undefined\n ? inheritedApi(source, def)\n : undefined;\n if (collection === def.collection && api === undefined) continue;\n if (collection !== def.collection) moved.set(def, collection);\n objects ??= { ...source.objects };\n objects[key] = {\n ...def,\n collection,\n // The catalog keeps granting the STI base's slugs; the route must keep\n // requiring them (#3770).\n ...(collection === def.collection\n ? {}\n : { permissionCollection: def.collection }),\n ...(api === undefined\n ? {}\n : { decoratorConfig: { ...def.decoratorConfig, api } }),\n } as SmartObjectDefinition;\n }\n if (!objects) return manifest;\n\n // A collection class follows its item class (the manifest generator gives it\n // the item's collection); left behind it would keep the shared route and\n // collide with its siblings' collection classes on custom-action routes.\n for (const [key, def] of Object.entries(source.objects)) {\n if (!isCollectionManifestClass(source, def)) continue;\n const item = resolveCollectionItemObject(source, def);\n const collection = item ? moved.get(item) : undefined;\n if (collection && def.collection !== collection) {\n objects[key] = {\n ...def,\n collection,\n permissionCollection: def.collection,\n } as SmartObjectDefinition;\n }\n }\n return { ...manifest, objects } as M;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,SAAS,aACP,UACA,KACS;CACT,MAAM,uBAAO,IAAI,IAAY;CAC7B,IAAI,QAAQ;CACZ,IAAI,aAAa,MAAM,oBAAoB,MAAM;CACjD,OAAO,cAAc,CAAC,KAAK,IAAI,UAAU,GAAG;EAC1C,KAAK,IAAI,UAAU;EACnB,MAAM,SAAS,yBAAyB,UAAU,YAAY,KAAK;EACnE,IAAI,CAAC,QAAQ,OAAO,KAAA;EAMpB,IAAI,EAFF,OAAO,eAAe,IAAI,cAC1B,OAAO,iBAAiB,kBAAkB,QAClC,OAAO,KAAA;EACjB,MAAM,MAAM,OAAO,iBAAiB;EACpC,IAAI,QAAQ,KAAA,GAAW,OAAO;EAC9B,QAAQ;EACR,aAAa,OAAO,oBAAoB,OAAO;CACjD;AAEF;;;;;;;;AASA,SAAgB,6BACd,UACA,QACA,OACG;CACH,MAAM,SAAS;CACf,MAAM,SAAS,IAAI,IAAI,MAAM;CAC7B,IAAI;CACJ,MAAM,wBAAQ,IAAI,IAAmC;CACrD,KAAK,MAAM,CAAC,KAAK,QAAQ,OAAO,QAAQ,OAAO,OAAO,GAAG;EACvD,MAAM,MAAM,MAAM,KAAK,GAAG;EAC1B,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,GAAG,GAAG;EAK9B,MAAM,aADJ,CAAC,IAAI,iBAAiB,cAAc,gBAAgB,QAAQ,GAAG,IAE7D,sBAAsB,IAAI,SAAS,IACnC,IAAI;EACR,MAAM,MACJ,IAAI,iBAAiB,QAAQ,KAAA,IACzB,aAAa,QAAQ,GAAG,IACxB,KAAA;EACN,IAAI,eAAe,IAAI,cAAc,QAAQ,KAAA,GAAW;EACxD,IAAI,eAAe,IAAI,YAAY,MAAM,IAAI,KAAK,UAAU;EAC5D,YAAY,EAAE,GAAG,OAAO,QAAQ;EAChC,QAAQ,OAAO;GACb,GAAG;GACH;GAGA,GAAI,eAAe,IAAI,aACnB,CAAC,IACD,EAAE,sBAAsB,IAAI,WAAW;GAC3C,GAAI,QAAQ,KAAA,IACR,CAAC,IACD,EAAE,iBAAiB;IAAE,GAAG,IAAI;IAAiB;GAAI,EAAE;EACzD;CACF;CACA,IAAI,CAAC,SAAS,OAAO;CAKrB,KAAK,MAAM,CAAC,KAAK,QAAQ,OAAO,QAAQ,OAAO,OAAO,GAAG;EACvD,IAAI,CAAC,0BAA0B,QAAQ,GAAG,GAAG;EAC7C,MAAM,OAAO,4BAA4B,QAAQ,GAAG;EACpD,MAAM,aAAa,OAAO,MAAM,IAAI,IAAI,IAAI,KAAA;EAC5C,IAAI,cAAc,IAAI,eAAe,YACnC,QAAQ,OAAO;GACb,GAAG;GACH;GACA,sBAAsB,IAAI;EAC5B;CAEJ;CACA,OAAO;EAAE,GAAG;EAAU;CAAQ;AAChC"}
|
|
@@ -11,7 +11,7 @@ export { loadVerifiedSmrtGenerationSnapshot, type SerializeSmrtGenerationSnapsho
|
|
|
11
11
|
* `objects` is an HTTP exposure boundary: every entry must be an exact,
|
|
12
12
|
* provider-qualified manifest key (for example, `@acme/widgets:Widget`).
|
|
13
13
|
*/
|
|
14
|
-
export interface SmrtConsumerSvelteKitOptions extends Partial<Pick<SvelteKitOptions, 'routesDir' | 'configPath' | 'configFileName' | 'kebabRoutes' | 'changesRoute' | 'eventsRoute' | 'resourcesRoute'>> {
|
|
14
|
+
export interface SmrtConsumerSvelteKitOptions extends Partial<Pick<SvelteKitOptions, 'routesDir' | 'objectsDir' | 'configPath' | 'configFileName' | 'kebabRoutes' | 'changesRoute' | 'eventsRoute' | 'resourcesRoute'>> {
|
|
15
15
|
objects: readonly string[];
|
|
16
16
|
}
|
|
17
17
|
export interface SmrtConsumerOptions {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/consumer-plugin/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,KAAK,EAAa,MAAM,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,EAEL,KAAK,6BAA6B,EACnC,MAAM,2BAA2B,CAAC;AAYnC,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,uCAAuC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/consumer-plugin/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,KAAK,EAAa,MAAM,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,EAEL,KAAK,6BAA6B,EACnC,MAAM,2BAA2B,CAAC;AAYnC,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,uCAAuC,CAAC;AAqB/C,OAAO,EACL,kCAAkC,EAClC,KAAK,sCAAsC,EAC3C,KAAK,8BAA8B,EACnC,KAAK,6BAA6B,EAClC,KAAK,0BAA0B,EAC/B,+BAA+B,EAC/B,4BAA4B,GAC7B,MAAM,2BAA2B,CAAC;AAqDnC;;;;GAIG;AACH,MAAM,WAAW,4BACf,SAAQ,OAAO,CACb,IAAI,CACF,gBAAgB,EACd,WAAW,GACX,YAAY,GACZ,YAAY,GACZ,gBAAgB,GAChB,aAAa,GACb,cAAc,GACd,aAAa,GACb,gBAAgB,CACnB,CACF;IACD,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,uCAAuC;IACvC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,6BAA6B,CAAC;IACnD;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,4BAA4B,CAAC;IACnD;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,oDAAoD;IACpD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,4BAA4B;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAmXD;;GAEG;AACH,wBAAgB,YAAY,CAAC,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAoYtE"}
|