@happyvertical/smrt-core 0.55.24 → 0.56.1

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 (119) hide show
  1. package/agents/collection-reads.md +6 -3
  2. package/agents/generators.md +53 -0
  3. package/agents/object-runtime.md +3 -0
  4. package/agents/recipes.md +7 -4
  5. package/dist/browser.d.ts +2 -0
  6. package/dist/browser.d.ts.map +1 -1
  7. package/dist/browser.js +3 -1
  8. package/dist/collection.d.ts +4 -0
  9. package/dist/collection.d.ts.map +1 -1
  10. package/dist/collection.js +19 -9
  11. package/dist/collection.js.map +1 -1
  12. package/dist/consumer-plugin/hosted-sti-collections.d.ts.map +1 -1
  13. package/dist/consumer-plugin/hosted-sti-collections.js +4 -2
  14. package/dist/consumer-plugin/hosted-sti-collections.js.map +1 -1
  15. package/dist/cookbook/engine/commands.d.ts.map +1 -1
  16. package/dist/cookbook/engine/commands.js +12 -0
  17. package/dist/cookbook/engine/commands.js.map +1 -1
  18. package/dist/cookbook/engine/defaults.d.ts.map +1 -1
  19. package/dist/cookbook/engine/defaults.js +8 -0
  20. package/dist/cookbook/engine/defaults.js.map +1 -1
  21. package/dist/cookbook/engine/document.d.ts +4 -0
  22. package/dist/cookbook/engine/document.d.ts.map +1 -1
  23. package/dist/cookbook/engine/document.js +11 -3
  24. package/dist/cookbook/engine/document.js.map +1 -1
  25. package/dist/cookbook/engine/engine.d.ts.map +1 -1
  26. package/dist/cookbook/engine/engine.js +3 -1
  27. package/dist/cookbook/engine/engine.js.map +1 -1
  28. package/dist/cookbook/engine/schemas.d.ts.map +1 -1
  29. package/dist/cookbook/engine/schemas.js +8 -0
  30. package/dist/cookbook/engine/schemas.js.map +1 -1
  31. package/dist/cookbook/engine/types.d.ts +5 -1
  32. package/dist/cookbook/engine/types.d.ts.map +1 -1
  33. package/dist/cookbook/engine/types.js.map +1 -1
  34. package/dist/decorators/index.d.ts +2 -0
  35. package/dist/decorators/index.d.ts.map +1 -1
  36. package/dist/decorators/index.js.map +1 -1
  37. package/dist/generators/index.d.ts +1 -0
  38. package/dist/generators/index.d.ts.map +1 -1
  39. package/dist/generators/index.js +2 -1
  40. package/dist/generators/mcp-runtime-template.d.ts +3 -0
  41. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  42. package/dist/generators/mcp-runtime-template.js +30 -4
  43. package/dist/generators/mcp-runtime-template.js.map +1 -1
  44. package/dist/generators/mcp.d.ts +5 -0
  45. package/dist/generators/mcp.d.ts.map +1 -1
  46. package/dist/generators/mcp.js +82 -9
  47. package/dist/generators/mcp.js.map +1 -1
  48. package/dist/generators/preflight-route.d.ts.map +1 -1
  49. package/dist/generators/preflight-route.js +3 -1
  50. package/dist/generators/preflight-route.js.map +1 -1
  51. package/dist/generators/rest.d.ts.map +1 -1
  52. package/dist/generators/rest.js +5 -0
  53. package/dist/generators/rest.js.map +1 -1
  54. package/dist/generators/swagger.d.ts.map +1 -1
  55. package/dist/generators/swagger.js +3 -1
  56. package/dist/generators/swagger.js.map +1 -1
  57. package/dist/generators.js +2 -1
  58. package/dist/index.d.ts +1 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +3 -1
  61. package/dist/manifest/browser-capability.generated.js +34 -5
  62. package/dist/manifest/browser-capability.generated.js.map +1 -1
  63. package/dist/manifest/index.js +1 -1
  64. package/dist/manifest/static-manifest.d.ts.map +1 -1
  65. package/dist/manifest/static-manifest.js +9 -1
  66. package/dist/manifest/static-manifest.js.map +1 -1
  67. package/dist/manifest/store.js +1 -1
  68. package/dist/manifest/store.js.map +1 -1
  69. package/dist/manifest.json +9 -1
  70. package/dist/mcp-serializer.d.ts +38 -0
  71. package/dist/mcp-serializer.d.ts.map +1 -0
  72. package/dist/mcp-serializer.js +60 -0
  73. package/dist/mcp-serializer.js.map +1 -0
  74. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/index.js +34 -0
  75. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/index.js.map +1 -0
  76. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/errors.js +343 -0
  77. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/errors.js.map +1 -0
  78. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/get-format.js +109 -0
  79. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/get-format.js.map +1 -0
  80. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/package-json-reader.js +102 -0
  81. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/package-json-reader.js.map +1 -0
  82. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/resolve.js +673 -0
  83. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/resolve.js.map +1 -0
  84. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/utils.js +32 -0
  85. package/dist/node_modules/.pnpm/import-meta-resolve@4.2.0/node_modules/import-meta-resolve/lib/utils.js.map +1 -0
  86. package/dist/object.d.ts +2 -0
  87. package/dist/object.d.ts.map +1 -1
  88. package/dist/object.js +51 -0
  89. package/dist/object.js.map +1 -1
  90. package/dist/operation-permission.d.ts +10 -0
  91. package/dist/operation-permission.d.ts.map +1 -0
  92. package/dist/operation-permission.js +36 -0
  93. package/dist/operation-permission.js.map +1 -0
  94. package/dist/registry/class-registration.d.ts.map +1 -1
  95. package/dist/registry/class-registration.js +12 -0
  96. package/dist/registry/class-registration.js.map +1 -1
  97. package/dist/registry/types.d.ts +18 -3
  98. package/dist/registry/types.d.ts.map +1 -1
  99. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  100. package/dist/scanner/manifest-generator.js +47 -0
  101. package/dist/scanner/manifest-generator.js.map +1 -1
  102. package/dist/scanner/types.d.ts +5 -0
  103. package/dist/scanner/types.d.ts.map +1 -1
  104. package/dist/scanner/types.js.map +1 -1
  105. package/dist/smrt-knowledge.json +18 -11
  106. package/dist/utils/import-project-vite.js +1 -1
  107. package/dist/utils/import-workspace-module.js +1 -1
  108. package/dist/vite-plugin/api-client-entries.d.ts.map +1 -1
  109. package/dist/vite-plugin/api-client-entries.js +1 -0
  110. package/dist/vite-plugin/api-client-entries.js.map +1 -1
  111. package/dist/vite-plugin/dev-plane-route.js +1 -1
  112. package/dist/vite-plugin/import-build-aware.js +1 -1
  113. package/dist/vite-plugin/index.js +1 -1
  114. package/dist/vite-plugin/resources-route.js +1 -1
  115. package/dist/vite-plugin/sveltekit-generator.d.ts +0 -4
  116. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  117. package/dist/vite-plugin/sveltekit-generator.js +37 -25
  118. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  119. package/package.json +16 -10
@@ -3,29 +3,29 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.55.24",
6
+ "packageVersion": "0.56.1",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "8642918ccc524246f2a1e63dae865d92e2eceebc62dafa8d92422a11c039dfae",
11
- "packageJson": "d2f3a12d1f9ff62918ec4dbe6d39cf9d0f7aba9c861a53b16f586b0deed6c695",
10
+ "manifest": "da6534a68906a70b9c595c895b35c758bc477608043670a7a4281ebfa46f51ed",
11
+ "packageJson": "28128c0b6fbd10f1c40f47690b71dbe43ff52f76504f386f8f52af69335958ac",
12
12
  "agents": "a55cc2d7dccf8f5dc9fd515361fddf719466159ee11e265285cf14b3fcdd96fa",
13
- "moduleDoc:agents/object-runtime.md": "93ba429467c5f3c886613fe5748e14844ceb8d2bbdebd47214a013a8e60b2a06",
13
+ "moduleDoc:agents/object-runtime.md": "fa70a9ead3464b1f9a451e385c59582d872944631db5ac3c7f64550c099dac40",
14
14
  "moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
15
- "moduleDoc:agents/collection-reads.md": "e5bb79b59c17e79a85a71580a019e6b0b641e498ac4d850073fd5ad634b57cb7",
15
+ "moduleDoc:agents/collection-reads.md": "39acea7faf3505d38bfb999e414f4c4c6f964ec7c3d19cc52993302c29223029",
16
16
  "moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5",
17
17
  "moduleDoc:agents/data-query.md": "1b72411d441ce2285bf0c89674e7aa996832a58b552b23a6d43834b907d91355",
18
18
  "moduleDoc:agents/schema-paths.md": "aef1b6fd687d0dbe444d20bf318e7c62fb9ef063ead933d2fd8168b87fee032e",
19
19
  "moduleDoc:agents/change-feed.md": "c3d6206758dd1d2e0475177e1938fb7d7b6cfe8a6c4d3c3b56ef62bc7bc7739d",
20
20
  "moduleDoc:agents/change-signals.md": "a2dbe7077483b5a19bd5ea9589b5a08fe847377c8e05d01d57f813c6d9cbc28b",
21
- "moduleDoc:agents/generators.md": "55244a454c2ab8937143600eb785f069d949d75fc4da8bfdf78fb801a59facc8",
21
+ "moduleDoc:agents/generators.md": "f880fdf8422ed6073feb74c486ad8fa15af0a64e3b2f3c146b9138c42441b146",
22
22
  "moduleDoc:agents/build-knowledge.md": "93ea3d5c708daea35ec7f90de81ed33974ce1610d76a2d1c32a77e149bec7ee8",
23
23
  "moduleDoc:agents/memory.md": "a86bdf370403d08ecad3be86e341eb322dfe6e6b7a4e8806a6f8be9bf3421da5",
24
24
  "moduleDoc:agents/system-diagnostics.md": "b7ccae18ee312bf788d5470f95fa78d3535f1789247d5372e40ca86538290772",
25
25
  "moduleDoc:agents/registry-snapshot.md": "6234aa540427396d1dda1825a4641b1072e247187d5ddf413ae46c691344ee30",
26
26
  "moduleDoc:agents/model-moves.md": "c64147fee1b7ec242fd1c178e7aea7cc90cafc704dee2738141c7a6d61403cd8",
27
27
  "moduleDoc:agents/run-once.md": "43c589a3cab2ff61147128014cc0084509faf623eb76860140d589539420a1f3",
28
- "moduleDoc:agents/recipes.md": "50ddedb14583e073c4af9c15142f4838c4326c1955931f743148e41e15dbdf38",
28
+ "moduleDoc:agents/recipes.md": "8be8ec8b49367b80caa2d9827db79424acfc99343445602d6964ea49139d865d",
29
29
  "moduleDoc:agents/cookbook.md": "20c256f4f6219f9b2add6365ef930e0bb3eda37e7866ddbc4bdd466830f76e4f",
30
30
  "moduleDoc:agents/cookbook-engine.md": "4ea0b3cdb952788372596cbf1f51e326b0d696302f6358a21e3330402ef1f8aa",
31
31
  "moduleDoc:agents/browser-entry.md": "04d26eff8a65f02e88b20cbb77b1f94f58e29b62178ac6fbec129ee436a732eb",
@@ -48,6 +48,7 @@
48
48
  "./manifest.json",
49
49
  "./manifest/discover-base-classes",
50
50
  "./manifest/discover-smrt-packages",
51
+ "./mcp-serializer",
51
52
  "./migrations",
52
53
  "./prebuild",
53
54
  "./prebuild/cli",
@@ -81,6 +82,7 @@
81
82
  "@oxc-project/runtime": "^0.150.0",
82
83
  "cheerio": "^1.2.0",
83
84
  "fast-glob": "3.3.3",
85
+ "import-meta-resolve": "4.2.0",
84
86
  "minimatch": "10.2.6",
85
87
  "oxc-transform": "0.150.0",
86
88
  "pluralize": "^8.0.0",
@@ -219,6 +221,7 @@
219
221
  "getItemClass",
220
222
  "getOrUpsert",
221
223
  "getStiChildMetaType",
224
+ "getStiChildMetaTypes",
222
225
  "hasBaseCreateLifecycle",
223
226
  "initialize",
224
227
  "list",
@@ -410,6 +413,10 @@
410
413
  "name": "getStiChildMetaType",
411
414
  "returns": "string | null"
412
415
  },
416
+ {
417
+ "name": "getStiChildMetaTypes",
418
+ "returns": "string[]"
419
+ },
413
420
  {
414
421
  "name": "hasBaseCreateLifecycle",
415
422
  "static": true,
@@ -1141,7 +1148,7 @@
1141
1148
  {
1142
1149
  "path": "agents/object-runtime.md",
1143
1150
  "module": "object-runtime",
1144
- "content": "# Object and collection runtime\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\nModels declaring `audit: true` use transactional collection CRUD with trusted\n`SmrtCollectionOptions.auditTrail` identity and writer. `collection.update()` checks\nregistered mutable fields; audited creates refuse natural-key overwrites.\nDirect object writes and custom overrides need explicit service recording.\nCascade reuse of a no-savepoint transaction requires an active framework-owned\ntransaction handle; raw caller transactions retain the ordinary delete limitation.\nSee [audit contract](../../../docs/content/audit-trail.md).\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [revision-guard.md](revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `normalizePersistenceData(data)` is the synchronous final derived-column hook:\n shared save preparation passes read-only snake-case row data after the complete polymorphic\n `toJSON()` / `transformJSON()` chain and UUID coercion, then merges the returned\n columns before every insert/update/upsert branch. Declare permitted snake-case\n columns with `getPersistenceDerivedColumns()`, preserving super declarations.\n Declarations must name registered schema fields; framework identity, tenant,\n revision, STI metadata and natural conflict columns are always rejected.\n Undeclared return keys reject before persistence. The hook receives a frozen\n shallow row copy. Derive only the declared columns; do not perform I/O.\n Preserve `super` results. It does not alter plain/public serialization;\n use `transformJSON()` for that existing contract. Ordinary saves and eligible\n junction batches share this preparation. A custom normalization override makes\n a junction ineligible for batching, preserving its virtual per-row save path.\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\nRelationship I/O resolves canonical source and target identities, not the public\ndisplay names. `relationship-loader.ts` owns target manifest hydration and inverse\nselection for lazy, eager, junction and latest-related reads. An unresolved exact\nconstructor or ambiguous target fails before querying or caching a foreign peer;\nlegacy external string targets retain manifest discovery. Junction column naming\nconventions still use display names. Cached reads retain their tenant rechecks.\n\n\n## Plain-object serialization\n\n`toPlainObject()` materializes the `toJSON()` / `transformJSON()` result directly,\nwithout an intermediate JSON string for ordinary payloads. Native `JSON.rawJSON()`\nliterals are decoded once with `JSON.parse()`. Nested hooks and JSON omission/coercion\nrules still apply. The active ancestor stack rejects cycles while allowing\nshared siblings, which become independent plain copies. Arrays skip boxed-value\nbrand checks; other objects use intrinsic brand checks without reading user\n`Symbol.toStringTag` getters. Customize the payload through `transformJSON()`.\nThe focused `to-plain-object.test.ts` suite compares legacy output and reports\nwarmed, interleaved per-row timings without a flaky timing assertion.\n\n`pnpm --filter @happyvertical/smrt-core test:browser` bundles the production\n`src/plain-json.ts` helper and executes it in Chromium with `process` and\n`JSON.isRawJSON` unavailable. It is an opt-in local/browser release gate rather\nthan part of the default core suite: it requires the repository's\n`playwright-core` dependency and an installed Chromium binary. Set\n`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chromium` to use an installed\nexecutable; otherwise Playwright's managed Chromium is used. The normal test\nsuite covers the same helper through `toPlainObject()` on Node.\n\n## SmrtCollection Query\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [collection-reads.md](collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\n`convertWhereKeys` must accept only operators executable by the SQL builder.\n`contains` and dot-notation JSON paths reject at the boundary; use `like` with\nexplicit wildcards. Adding operators requires SQL support first.\n`src/__tests__/issue-2276-where-contract.test.ts` executes the accepted set.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [query-bounds.md](query-bounds.md).\n\n\n## Junction reads and compatible writes\n\n`byLeft(id, { relationship: 'attachment', limit: 20, offset: 40 })` and\n`byRight()` forward `limit`/`offset` to the collection's existing bounds parser;\nother options remain field filters. Pagination is read-only: `detach()` still\nrequires field filters and never deletes a page. Cursor pagination is not part\nof this API; callers can use deterministic limit/offset pages.\n\n`setLinks()` preserves replacement semantics: even retained right IDs get new\njunction IDs/timestamps and delete/create change entries. `detach()` and\n`setLinks()` batch compatible models automatically. Within 100 removed + added\nrows and 900 inserted bind values, a warm SQLite/DuckDB/PostgreSQL operation uses\nconstant framework SQL: one snapshot, grouped owned-memory cleanup and delete,\none multi-row natural-key upsert, and one feed append per nonempty mutation\nphase. Bootstrap, retries, and caller-defined work are excluded. The read\nsnapshot and in-memory work remain proportional to link count.\n\nEligibility is deliberately conservative and rechecked each call: base runtime\nmethods/accessors and collection create/attach behavior, one non-STI table,\ncompiler-owned declarative validators, no validated cross-package references,\nno embedding generation, no incoming typed references, non-NULL unique\nconflict values with no duplicate input conflict keys, and explicit consent\nfrom every mutation interceptor. Otherwise the original virtual per-row path\nruns. Larger sets retain that fallback; there is no truncated replacement.\nPolymorphic association cleanup remains grouped through the owning cascade API\nand invalidates every affected table. JSON export adapters retain their ordinary\nlifecycle; unrecognized transaction-only DuckDB handles also fall back.\nThe tenancy interceptor consents only without directory dispatch/custom error\ncallbacks. Tenant checks and auto-population still run per row. The change-feed\ninterceptor appends every row and preserves tenant IDs and tombstones.\n\nBulk lifecycle preparation and completion are owned by `SmrtObject`; collection\ninitialization shares `createUnsaved()` with `create()`. Additional interceptors\nmay supply `bulkMutation.compatible()` only if grouped before/persist/after\nphases preserve their behavior and before hooks have no side effects beyond\ninstance/context mutation. Preparation can discover an unsupported shape and\nfall back; before hooks must tolerate that rehearsal. Unknown interceptors never\nopt in implicitly.\nThe compiler marks its own callback-free validators by function identity.\n\nThe whole replacement remains nontransactional unless the caller supplies a\ntransaction. A delete group is atomic with its owned-memory cleanup and each\nmulti-row insert is atomic, so database failure can leave the deletion phase\ncommitted without new links. No per-row prefix is promised for a failed batch.\nThe batch delete reuses an existing DuckDB transaction when the SDK explicitly\nrefuses savepoint nesting before running any work; it never replays a callback.\nThe legacy fallback retains its existing detectable nested-transaction refusal\nfor ordinary deletes inside caller-owned DuckDB transactions\n([#2824](https://github.com/happyvertical/smrt/issues/2824)).\nPublic ordinary `save()` still owns revision CAS; batches only create new\ninstances and never use this path to overwrite a loaded revision.\n\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n- Tenancy (`dispatch/tenant-resolver.ts`): `emit` stamps the server-derived\n tenant; no context stores `tenant_id` NULL (global). Under an active tenant,\n processing (`process`/`retry`/`cleanup`) matches that tenant plus global rows,\n but inspection reads (`list`/`get`) match that tenant only — global rows are\n system-only because their payload may belong to another tenant (#3301). Emit\n tenant-owned deliveries inside the tenant context so the owner can see them.\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Columns win over `_meta_data` (#3238): `formatDataJs` never lets a meta key\n shadow a column the row carries, except a field the class declares\n `@meta()` (a same-named column then belongs to an STI sibling). `id`, tenant-ownership and `_`-prefixed\n keys (`isReservedMetaDataKey`, inherited tenant fields included) are dropped\n from the blob on hydration and save, object or JSON text (`sanitizeMetaData`).\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n"
1151
+ "content": "# Object and collection runtime\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\nModels declaring `audit: true` use transactional collection CRUD with trusted\n`SmrtCollectionOptions.auditTrail` identity and writer. `collection.update()` checks\nregistered mutable fields; audited creates refuse natural-key overwrites.\nDirect object writes and custom overrides need explicit service recording.\nCascade reuse of a no-savepoint transaction requires an active framework-owned\ntransaction handle; raw caller transactions retain the ordinary delete limitation.\nSee [audit contract](../../../docs/content/audit-trail.md).\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [revision-guard.md](revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `normalizePersistenceData(data)` is the synchronous final derived-column hook:\n shared save preparation passes read-only snake-case row data after the complete polymorphic\n `toJSON()` / `transformJSON()` chain and UUID coercion, then merges the returned\n columns before every insert/update/upsert branch. Declare permitted snake-case\n columns with `getPersistenceDerivedColumns()`, preserving super declarations.\n Declarations must name registered schema fields; framework identity, tenant,\n revision, STI metadata and natural conflict columns are always rejected.\n Undeclared return keys reject before persistence. The hook receives a frozen\n shallow row copy. Derive only the declared columns; do not perform I/O.\n Preserve `super` results. It does not alter plain/public serialization;\n use `transformJSON()` for that existing contract. Ordinary saves and eligible\n junction batches share this preparation. A custom normalization override makes\n a junction ineligible for batching, preserving its virtual per-row save path.\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\nRelationship I/O resolves canonical source and target identities, not the public\ndisplay names. `relationship-loader.ts` owns target manifest hydration and inverse\nselection for lazy, eager, junction and latest-related reads. An unresolved exact\nconstructor or ambiguous target fails before querying or caching a foreign peer;\nlegacy external string targets retain manifest discovery. Junction column naming\nconventions still use display names. Cached reads retain their tenant rechecks.\n\n\n## Plain-object serialization\n\n`toPlainObject()` materializes the `toJSON()` / `transformJSON()` result directly,\nwithout an intermediate JSON string for ordinary payloads. Native `JSON.rawJSON()`\nliterals are decoded once with `JSON.parse()`. Nested hooks and JSON omission/coercion\nrules still apply. The active ancestor stack rejects cycles while allowing\nshared siblings, which become independent plain copies. Arrays skip boxed-value\nbrand checks; other objects use intrinsic brand checks without reading user\n`Symbol.toStringTag` getters. Customize the payload through `transformJSON()`.\nThe focused `to-plain-object.test.ts` suite compares legacy output and reports\nwarmed, interleaved per-row timings without a flaky timing assertion.\n\n`pnpm --filter @happyvertical/smrt-core test:browser` bundles the production\n`src/plain-json.ts` helper and executes it in Chromium with `process` and\n`JSON.isRawJSON` unavailable. It is an opt-in local/browser release gate rather\nthan part of the default core suite: it requires the repository's\n`playwright-core` dependency and an installed Chromium binary. Set\n`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chromium` to use an installed\nexecutable; otherwise Playwright's managed Chromium is used. The normal test\nsuite covers the same helper through `toPlainObject()` on Node.\n\n## SmrtCollection Query\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [collection-reads.md](collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\n`convertWhereKeys` must accept only operators executable by the SQL builder.\n`contains` and dot-notation JSON paths reject at the boundary; use `like` with\nexplicit wildcards. Adding operators requires SQL support first.\n`src/__tests__/issue-2276-where-contract.test.ts` executes the accepted set.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [query-bounds.md](query-bounds.md).\n\n\n## Junction reads and compatible writes\n\n`byLeft(id, { relationship: 'attachment', limit: 20, offset: 40 })` and\n`byRight()` forward `limit`/`offset` to the collection's existing bounds parser;\nother options remain field filters. Pagination is read-only: `detach()` still\nrequires field filters and never deletes a page. Cursor pagination is not part\nof this API; callers can use deterministic limit/offset pages.\n\n`setLinks()` preserves replacement semantics: even retained right IDs get new\njunction IDs/timestamps and delete/create change entries. `detach()` and\n`setLinks()` batch compatible models automatically. Within 100 removed + added\nrows and 900 inserted bind values, a warm SQLite/DuckDB/PostgreSQL operation uses\nconstant framework SQL: one snapshot, grouped owned-memory cleanup and delete,\none multi-row natural-key upsert, and one feed append per nonempty mutation\nphase. Bootstrap, retries, and caller-defined work are excluded. The read\nsnapshot and in-memory work remain proportional to link count.\n\nEligibility is deliberately conservative and rechecked each call: base runtime\nmethods/accessors and collection create/attach behavior, one non-STI table,\ncompiler-owned declarative validators, no validated cross-package references,\nno embedding generation, no incoming typed references, non-NULL unique\nconflict values with no duplicate input conflict keys, and explicit consent\nfrom every mutation interceptor. Otherwise the original virtual per-row path\nruns. Larger sets retain that fallback; there is no truncated replacement.\nPolymorphic association cleanup remains grouped through the owning cascade API\nand invalidates every affected table. JSON export adapters retain their ordinary\nlifecycle; unrecognized transaction-only DuckDB handles also fall back.\nThe tenancy interceptor consents only without directory dispatch/custom error\ncallbacks. Tenant checks and auto-population still run per row. The change-feed\ninterceptor appends every row and preserves tenant IDs and tombstones.\n\nBulk lifecycle preparation and completion are owned by `SmrtObject`; collection\ninitialization shares `createUnsaved()` with `create()`. Additional interceptors\nmay supply `bulkMutation.compatible()` only if grouped before/persist/after\nphases preserve their behavior and before hooks have no side effects beyond\ninstance/context mutation. Preparation can discover an unsupported shape and\nfall back; before hooks must tolerate that rehearsal. Unknown interceptors never\nopt in implicitly.\nThe compiler marks its own callback-free validators by function identity.\n\nThe whole replacement remains nontransactional unless the caller supplies a\ntransaction. A delete group is atomic with its owned-memory cleanup and each\nmulti-row insert is atomic, so database failure can leave the deletion phase\ncommitted without new links. No per-row prefix is promised for a failed batch.\nThe batch delete reuses an existing DuckDB transaction when the SDK explicitly\nrefuses savepoint nesting before running any work; it never replays a callback.\nThe legacy fallback retains its existing detectable nested-transaction refusal\nfor ordinary deletes inside caller-owned DuckDB transactions\n([#2824](https://github.com/happyvertical/smrt/issues/2824)).\nPublic ordinary `save()` still owns revision CAS; batches only create new\ninstances and never use this path to overwrite a loaded revision.\n\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n- Tenancy (`dispatch/tenant-resolver.ts`): `emit` stamps the server-derived\n tenant; no context stores `tenant_id` NULL (global). Under an active tenant,\n processing (`process`/`retry`/`cleanup`) matches that tenant plus global rows,\n but inspection reads (`list`/`get`) match that tenant only — global rows are\n system-only because their payload may belong to another tenant (#3301). Emit\n tenant-owned deliveries inside the tenant context so the owner can see them.\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Columns win over `_meta_data` (#3238): `formatDataJs` never lets a meta key\n shadow a column the row carries, except a field the class declares\n `@meta()` (a same-named column then belongs to an STI sibling). `id`, tenant-ownership and `_`-prefixed\n keys (`isReservedMetaDataKey`, inherited tenant fields included) are dropped\n from the blob on hydration and save, object or JSON text (`sanitizeMetaData`).\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n- Multi-level STI collections read their own discriminator and every registered descendant, never sibling branches. Decorate every intermediate with `@smrt()`; registration rejects an undecorated intermediate beneath an STI root.\n- `@smrt({ abstract: true })` makes a registered STI type readable but prevents saving an instance with its own discriminator. A base collection can still create a concrete descendant by passing that descendant's `_meta_type`. Generated surfaces expose only `list` and `get` for abstract models, including when custom actions were explicitly included.\n- Set `validateSubtype: true` on a foreign key or cross-package reference targeting an STI child when it must reject sibling rows. Save checks through the child's collection, and `loadRelated()` rejects a stored sibling row. The physical FK targets the shared root table, so the collection's discriminator and tenant read scope provide the subtype check. Saves fail when the subtype target cannot be loaded, even if ordinary cross-package validation is disabled.\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n"
1145
1152
  },
1146
1153
  {
1147
1154
  "path": "agents/revision-guard.md",
@@ -1151,7 +1158,7 @@
1151
1158
  {
1152
1159
  "path": "agents/collection-reads.md",
1153
1160
  "module": "collection-reads",
1154
- "content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Collection reads\n\nThis module covers bounded collection reads beyond the basic query contract in\n`packages/core/AGENTS.md`.\n\n## Projections and related rows\n\n`list({ select })` uses SMRT field names, maps them to database columns, and\nreturns plain rows without hydrating objects. It composes with `where`,\n`orderBy`, `limit`, and `offset`, runs normal `beforeList`/tenant interceptors,\nand is limited to column-backed fields; it cannot combine with `include`.\n\n## Bounded STI discriminator scopes\n\nAn STI child collection remains scoped to its own qualified `_meta_type` by\ndefault. A migration that must read registered sibling types may opt into an\nexplicit allowlist:\n\n```typescript\nawait impressionEvents.list({\n stiScope: {\n types: [\n '@anytown/advertising:AdImpression',\n '@anytown/advertising:LegacyAdImpression',\n ],\n },\n orderBy: 'created_at ASC',\n limit: 100,\n});\n```\n\n`stiScope.types` accepts 1–50 unique, qualified, registered types, all sharing\nthe child collection's STI root. Empty, simple-name, unknown, duplicate,\nunrelated, and non-child scopes fail at the collection boundary. The option is\nsupported by `list()`, `count()`, `counts()`, `facets()`, and\n`listWithLatestRelated()`. These methods retain their normal field validation,\nprojection or polymorphic hydration, pagination and cache-key construction;\nnormal read and tenant interceptors still run and are ANDed with the allowlist.\nPoint reads through `get()` remain child-only; use a bounded\n`list({ where, limit: 1, stiScope })` migration read when sibling hydration is\nrequired.\n\nFor one child per parent, use\n[`latest-related.md`](latest-related.md). It uses a portable ranked CTE,\ndeclared primary keys, adapter-specific offset-only syntax, explicit aliases,\nand hydrates only the visible parent page.\n\n## Facets, counts, and read plans\n\n`collection.facets({ fields, where })` runs one bounded `GROUP BY` per requested\nfield and returns `{ field, values: [{ value, count }] }`. It accepts at most 20\nfields, clamps value limits to 1,000 and the collection ceiling, never hydrates\nobjects, and applies the same read/tenant/sensitive-field rails as `select`.\nStored array/string-list values are grouped as stored; they are not unnested.\n`collection.counts({ where })` returns `{ total, filtered }` through two scoped\n`COUNT(*)` queries. Local coverage is SQLite/DuckDB; optional scalar PostgreSQL\ncoverage requires `SMRT_TEST_POSTGRES_URL`.\n\n`executeCollectionReadPlan()` bounds concurrent reads across independent\ncollections while preserving the normal registry and collection options. The\ncaller supplies a positive `maxConcurrency`; the executor does not compose SQL,\ncache, or alter pool defaults, and drains already-started work before returning\nthe first error.\n\n`where` operators must remain aligned with `@happyvertical/sql`'s `buildWhere`:\n`=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, and `like`. Arrays imply `IN`,\nand null values render `IS NULL`/`IS NOT NULL`. `contains` and dot-notation JSON\npaths are intentionally rejected until the SQL layer supports them.\n\nFor subclass-owned bounded SQL, `resolveListReadPredicate()` provides SQL scope\nplus `finish(instances)`. Call `finish` exactly once on the final hydrated page\n(including empty pages), after raw-query hooks and annotations, outside provider\nfallback catches. It invokes normal `afterList` using the original `beforeList`\ncontext and model identity. It does not fetch replacement rows after filtering.\nID-only consumers have no hydrated page and do not call this completion step.\n\n`semanticSearchIdsWithAvailability()` is the protected fallback-aware boundary:\nonly absent embedding configuration or a failed `provider.embed()` returns\n`available: false`. Invalid options, authorization, ranking, query, and caller\noverride failures propagate regardless of their error class. Public\n`semanticSearchIds()` keeps its array/throw contract by throwing the returned\nprovider error. Consumers must branch on availability, never classify thrown\n`EmbeddingUnavailableError` values from an entire search as provider failures.\n\nSemantic ID eligibility binds its complete beforeList/STI/where predicate once\nin a CTE per candidate chunk. Only the scalar eligibility mask leaves the\napplication database. Candidate chunks use the remaining conservative 999-bind\nbudget (minimum 1); a predicate already larger than that budget retains the\nadapter’s existing capacity instead of being rejected by a new API limit.\n"
1161
+ "content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Collection reads\n\nThis module covers bounded collection reads beyond the basic query contract in\n`packages/core/AGENTS.md`.\n\n## Projections and related rows\n\n`list({ select })` uses SMRT field names, maps them to database columns, and\nreturns plain rows without hydrating objects. It composes with `where`,\n`orderBy`, `limit`, and `offset`, runs normal `beforeList`/tenant interceptors,\nand is limited to column-backed fields; it cannot combine with `include`.\n\n## Bounded STI discriminator scopes\n\nAn STI child collection reads its own qualified `_meta_type` and all registered descendants by default. A migration that must read registered sibling types may opt into an\nexplicit allowlist:\n\n```typescript\nawait impressionEvents.list({\n stiScope: {\n types: [\n '@anytown/advertising:AdImpression',\n '@anytown/advertising:LegacyAdImpression',\n ],\n },\n orderBy: 'created_at ASC',\n limit: 100,\n});\n```\n\n`stiScope.types` accepts 1–50 unique, qualified, registered types, all sharing\nthe child collection's STI root. Empty, simple-name, unknown, duplicate,\nunrelated, and non-child scopes fail at the collection boundary. The option is\nsupported by `list()`, `count()`, `counts()`, `facets()`, and\n`listWithLatestRelated()`. These methods retain their normal field validation,\nprojection or polymorphic hydration, pagination and cache-key construction;\nnormal read and tenant interceptors still run and are ANDed with the allowlist.\nPoint reads through `get()` remain within the child branch; use a bounded\n`list({ where, limit: 1, stiScope })` migration read when sibling hydration is\nrequired.\n\nRaw SQL helpers can use `getStiChildMetaTypes()` to obtain the same branch\ndiscriminator set. The older `getStiChildMetaType()` returns only the class's\nown discriminator for callers that intentionally require an exact subtype.\n\nFor one child per parent, use\n[`latest-related.md`](latest-related.md). It uses a portable ranked CTE,\ndeclared primary keys, adapter-specific offset-only syntax, explicit aliases,\nand hydrates only the visible parent page.\n\n## Facets, counts, and read plans\n\n`collection.facets({ fields, where })` runs one bounded `GROUP BY` per requested\nfield and returns `{ field, values: [{ value, count }] }`. It accepts at most 20\nfields, clamps value limits to 1,000 and the collection ceiling, never hydrates\nobjects, and applies the same read/tenant/sensitive-field rails as `select`.\nStored array/string-list values are grouped as stored; they are not unnested.\n`collection.counts({ where })` returns `{ total, filtered }` through two scoped\n`COUNT(*)` queries. Local coverage is SQLite/DuckDB; optional scalar PostgreSQL\ncoverage requires `SMRT_TEST_POSTGRES_URL`.\n\n`executeCollectionReadPlan()` bounds concurrent reads across independent\ncollections while preserving the normal registry and collection options. The\ncaller supplies a positive `maxConcurrency`; the executor does not compose SQL,\ncache, or alter pool defaults, and drains already-started work before returning\nthe first error.\n\n`where` operators must remain aligned with `@happyvertical/sql`'s `buildWhere`:\n`=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, and `like`. Arrays imply `IN`,\nand null values render `IS NULL`/`IS NOT NULL`. `contains` and dot-notation JSON\npaths are intentionally rejected until the SQL layer supports them.\n\nFor subclass-owned bounded SQL, `resolveListReadPredicate()` provides SQL scope\nplus `finish(instances)`. Call `finish` exactly once on the final hydrated page\n(including empty pages), after raw-query hooks and annotations, outside provider\nfallback catches. It invokes normal `afterList` using the original `beforeList`\ncontext and model identity. It does not fetch replacement rows after filtering.\nID-only consumers have no hydrated page and do not call this completion step.\n\n`semanticSearchIdsWithAvailability()` is the protected fallback-aware boundary:\nonly absent embedding configuration or a failed `provider.embed()` returns\n`available: false`. Invalid options, authorization, ranking, query, and caller\noverride failures propagate regardless of their error class. Public\n`semanticSearchIds()` keeps its array/throw contract by throwing the returned\nprovider error. Consumers must branch on availability, never classify thrown\n`EmbeddingUnavailableError` values from an entire search as provider failures.\n\nSemantic ID eligibility binds its complete beforeList/STI/where predicate once\nin a CTE per candidate chunk. Only the scalar eligibility mask leaves the\napplication database. Candidate chunks use the remaining conservative 999-bind\nbudget (minimum 1); a predicate already larger than that budget retains the\nadapter’s existing capacity instead of being rejected by a new API limit.\n"
1155
1162
  },
1156
1163
  {
1157
1164
  "path": "agents/query-bounds.md",
@@ -1181,7 +1188,7 @@
1181
1188
  {
1182
1189
  "path": "agents/generators.md",
1183
1190
  "module": "generators",
1184
- "content": "# smrt-core/code generators\n\nModule semantics for `src/generators/` + `src/vite-plugin/`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Code Generators\n\n| Generator | Location | Output |\n|-----------|----------|--------|\n| REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |\n| MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |\n| Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |\n\nThe same web virtual module exports `webMcpToolDefinitions` (#2518), a\ncanonical per-tool array selected independently of list materialization. Every\nnon-empty canonical API action set contributes tools, so get-only and\ncustom-action-only models are discoverable; custom actions declared on a\n`SmrtCollection` merge into the owning row collection. Each definition carries\ncomplete route and invalidation metadata. `collectionDefinitions` and its\nembedded descriptor copy remain unchanged for existing cache-backed consumers.\n\nGenerated API clients share `selectApiClientEntries()` across the runtime Vite\nmodule, its ambient declaration, and physical prebuild declarations. When a\ncollection class and its populated model share an endpoint, the model owns the\ncanonical collection key and row payload schema; the collection class remains\navailable under a deterministic class-derived secondary key. Selection and\ncollision suffixes must not depend on manifest insertion order (#2027).\nRoute emission iterates manifest objects in the same deterministic\nqualified-identity order (`orderedManifestObjectEntries`), so when two models\nshare one collection the last writer of each route file — and therefore the\nCRUD method set `resolveGeneratedEndpointCrudMethods` mirrors into\n`smrt-client.d.ts` — is stable across scan orders (#2754).\nFor aggregated manifests, inheritance and item-type references resolve exact\nqualified names first, then package-local simple names, then a stable identity\nfallback so duplicate class names across packages cannot reintroduce ordering.\n\nThe web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Four co-managed emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), the physical `@smrt/web` d.ts (`prebuild/index.ts`), and the hand-written type mirror in `@happyvertical/smrt-web` (`packages/smrt-web/src/index.ts` — dependency-free, so textual sync only).\n\n`webMcpToolDefinitions` is deliberately outside that digest: tool-only route,\nidentifier, or annotation changes cannot alter persisted row hydration.\n\nPer-field web emission (#2046): `buildWebFieldDefinitions` carries `description` (from `@field({ description })`) and sanitized `ui` hints (from `@field({ ui: { basic, group, order, locked } })`, read off the manifest `_meta.ui` bag through per-key type guards) into each emitted field definition, and `buildWebToolDescriptors` threads the same `description` into browser MCP tool schemas. `sensitive`/`transient` fields are excluded from emission entirely, so their descriptions never ship. Both keys are conditional, so hint-less schemas emit byte-identical definitions (and hashes) as before; adding a description/ui hint changes the manifest hash — deliberate over-invalidation, harmless per the #1764 contract.\n\n## Generated MCP server output language\n\n`MCPGenerator` builds every file as TypeScript, so the requested `outputPath`\nextension decides what is written (#2279). `.ts`/`.mts` targets keep the source\nverbatim for `tsx` or Node type stripping — which is why the generated source\nmust stay erasable-syntax-only (no parameter properties, enums, or namespaces).\nEvery other target (`.smrt/mcp-server/index.js` by default) is transpiled to\nJavaScript with lazily loaded `oxc-transform` before writing, because the\nprinted run script and the generated `claude-config.example.json` both invoke\nit with plain `node`. Ordinary core imports and `.ts`/`.mts` output therefore\ndo not load OXC's native bindings.\nThis keeps `typescript` dev-only in `@happyvertical/smrt-core`; generated MCP\nsource must remain erasable-syntax-only. A `.cjs`/`.cts` target is rejected\noutright: generated servers are ES modules. `src/generators/mcp-emit.ts` owns\nthose decisions — do not reintroduce a bare `writeFile` of generated source.\n\nModular output writes `config`, `tools/index`, and `handlers/index` with the\nentry point's own extension, and emits the entry's relative import specifiers\nwith that same extension, so the files it imports both exist and load with the\nsame module semantics — an `.mjs` entry gets `.mjs` siblings, not `.js` ones a\nCommonJS package would then parse as CommonJS. The entry is written at the\nrequested path rather than a hardcoded `index.js`.\nGenerated code also has to be valid in an ES module: `arguments` is not a legal\nbinding name there, however convenient it reads.\n\n## Browser-plane playbook preflight route (#2590)\n\n`GET {basePath}/_preflight?key=<playbook key>` (`src/generators/preflight-route.ts`)\nis an advisory, read-effect, idempotent report of what a caller's playbook would\nbe allowed to do — capability *selection*, never authorization. Resolution and\nverdict shaping live in `@happyvertical/smrt-playbooks`, which depends on this\npackage, so core takes the evaluator as the `APIConfig.playbookPreflight` seam and\nthe dependency stays one-way. Without a provider the route 404s.\n\n**`authMiddleware` is never invoked by preflight**, and that is enforced\nstructurally rather than by discipline: `PlaybookPreflightRouteOptions` has no\nauth member of any kind, and `rest.ts` passes the boolean `appAuthConfigured`\ninstead — so there is no handle in the module to invoke by mistake. A synthetic-\n`Request` dry run is explicitly not an option: the middleware is request-bound,\nreturns a `Response` rather than a boolean, and may consult session stores,\nrate-limit, or audit. The app-auth layer therefore reports `unknown`, which is the\nhonest answer, and a future `authPredicate` seam can fill it in without changing\nthe contract.\n\nThe static layers preflight predicts against are exported from the same module —\n`isApiActionEnabledForObject`, `isRestActionRoutable`, `isRestRoutePublic`,\n`restFieldReadPermissions`, `restMethodForApiAction`,\n`resolveRegisteredObjectName` — and `APIGenerator`'s own\n`isApiActionEnabled` / `isRoutePublic` now delegate to them, so the route and the\nprediction of the route cannot drift. Exposure and existence are separate\nquestions: `include`/`exclude` gate a route, they do not conjure one, so\n`isRestActionRoutable` additionally requires a custom action to be declared in\n`api.routes` — the only map `dispatchCustomCollectionAction` iterates. A custom\naction is predicted against the verb its own route config declares, so a\n`public: 'read'` opt-out neither silently covers a `POST` action nor falsely\ndenies a declared `GET` one. Every unresolvable key returns the provider's single uniform\n\"unavailable\" body with an unconditional 200: unknown and unauthorized keys are\nindistinguishable at the HTTP layer too.\n\n## Emitted agent surface (#2591)\n\nGenerated model tools have always been build-time artifacts — virtual module,\nmanifest, knowledge graph. View intents (#2588) and playbooks (#2589) existed\nonly once something mounted, so \"what can an agent do in this app\" had no answer\nshort of enumerating every route. This closes that.\n\nThe same OXC scan that builds the manifest also runs the scanner's\nagent-surface matcher (`ScanResults.agentSurface`). `smrtPlugin()` captures it\nin `scanWithOxc`, projects it with `toKnowledgeAgentSurface`, and passes it to\n`buildDomainKnowledgeManifest` as `agentSurface`. Note that declaration\ndiscovery is NOT bound to the plugin's `include` glob — an app that scans\n`src/lib/objects/**` for models still has its `src/lib/agent/*.intents.ts`\nsidecars found (see `packages/scanner/AGENTS.md`). Two more consequences worth\nholding onto:\n\n- **It never touches `manifest.json`.** The runtime manifest stays\n runtime-focused; the agent-addressable surface is an agent/developer contract,\n so it lands in `.smrt/smrt-knowledge.json` and `dist/smrt-knowledge.json`\n only, under `agentSurface: { intents, playbooks, diagnostics }`.\n- **It is passed in, not scanned in `knowledge.ts`.** The scanner carries a\n native parser binary and `smrt-core`'s main entry is browser-reachable, so\n core's sync knowledge builder must not import it. The Vite plugin already\n imports the scanner lazily on the Node side and is the only caller that writes\n this artifact.\n\nThe field is **omitted entirely** when a package declares nothing, which is what\nmakes it additive in practice rather than only on paper: every existing\npackage's checked-in artifact stays byte-identical.\n\nEach declaring module gets a `sourceHashes` entry under the\n`agentSurface:<package-relative path>` prefix (`AGENT_SURFACE_HASH_PREFIX`), so\nEDITING an intent sidecar marks the artifact stale exactly like editing\n`AGENTS.md` does (`stale-domain-knowledge`).\n\nHashes alone cannot see an **added** declaration, though: a brand-new sidecar\nhas no recorded hash to mismatch, the runtime manifest never carries intents,\nand `AGENTS.md` is untouched — so every other signal stays green while the\nartifact omits a real operation. `dev:knowledge-check` therefore also re-derives\nthe declaration SET from source and compares it to the artifact by identity,\nreporting either direction as `stale-agent-surface`. The scan is bounded like\nthe numeric-precision lint: `src` only, behind the scanner's token pre-filter.\n\nThat re-derivation must model what the EMITTER sees, not merely what is on\ndisk, or it reports drift no rebuild can clear. Which files count is decided by\nthe scanner's exported `isAgentSurfaceSourcePath` — the same predicate the\nemitter itself uses, never a list copied into the checker — and the per-file\nresults run through `mergeAgentSurfaces` before comparing, because the merge is\nwhere a duplicate identity and a derived tool-name collision are resolved and\nthe artifact is the merged result.\nDiagnostics are compared alongside identities: a sidecar containing only a\ncomputed declaration adds no identity and has no prior hash, so without that,\n\"a diagnostic, never silence\" would quietly become \"a diagnostic, until the\nartifact goes stale\". The walk covers `<pkg>/src` while the emitter globs the\nwhole project root, so an emitted entry from outside `src` is not reported as\nmissing — this check did not look there, and claiming otherwise would be an\nerror nothing could clear.\n\nBoth `stale-*` codes are warnings by default and errors under `--strict`, which\nis what CI runs. Alongside them: `agent-surface-missing-identity`,\n`agent-surface-duplicate-identity`, and `agent-surface-empty-playbook` are\nerrors, and `agent-surface-not-static` and `agent-surface-tool-name-collision`\nare warnings. A cross-file duplicate arrives as a *diagnostic* rather than two\nentries — the scanner's merge already dropped the loser — so that diagnostic\nmaps to the duplicate error rather than the not-static warning; otherwise the\nerror would be unreachable for the case it exists to catch.\n\nA `tool-name-collision` diagnostic (#2725) is derived from the MANIFEST, not\nfrom the declaration: the plugin compares each emitted intent against\n`buildWebMcpToolDefinitions` for the same build, which is already filtered by\nthe exposure policy, and appends the result to the surface. So it is excluded\nfrom the drift comparison above — that pass re-derives declarations from source\nalone and has no manifest, so counting one as \"declared\" would report every\ncollision as drift no rebuild could clear. The check is advisory and drops\nnothing: `defineIntent` accepts the id, so the intent is emitted, and the\ndocument-global tool-name lock (#2613) rejects whichever registers second — the\nbuild-time notice is the earlier, cheaper form of the same answer. A build\ncannot see the provider's WebMCP `namespace` or `effects` policy, either of\nwhich separates the pair outright, so the message states that precondition\nrather than asserting the collision.\n\n`smrt doctor` prints the whole surface — model tools, intents, playbooks — from\nthese artifacts alone, with no application running.\n\n## Custom-action contract\n\n`resolveCustomActionMetadata()` is the common discovery and invocation contract\nfor generated REST routes and API clients, MCP, CLI, WebMCP, and simple\nREST-resource discovery. Receiver scope comes from the executable method, never a\nconfiguration-only `api.routes[name].scope` override: instance model methods\nare item-scoped and require `id`; static model methods and recognized\n`SmrtCollection` methods are collection-scoped and do not accept `id`. Route\nconfiguration may still choose its path and HTTP verb, but it cannot turn an\ninstance call into `ClassRef.action` or vice versa.\n\nWhen scanner method metadata exists, discovery projects each named parameter\nand its JSON-schema type, and invokers pass the values positionally in declared\norder. The legacy single `options` bag remains compatible when metadata is\nabsent (or the declared method takes `options`). Do not infer this from runtime\nfunction arity. An omitted typed `options` parameter remains `undefined`, so a\nmethod's JavaScript default initializer continues to apply; an explicit `null`\nremains `null`. Flat tool and CLI inputs reserve `id` for receiver parsing. If\nan action declares an `id` parameter, its flat MCP/WebMCP field is `actionId`\n(and CLI uses `--action-id`); REST keeps its independent path/body\nnamespaces. Typed CLI actions may use standard flag names such as `limit`,\n`offset`, `where`, and `format` without those values being stripped as CRUD\nflags.\n\nCustom actions may return an explicit, domain-neutral failure object with\n`ok: false`, `code`, and `message` plus optional `status`, `details`,\n`retryable`, and `correlationId`. `normalizeCustomActionFailure()` redacts it;\ngenerated REST returns `{ error: failure }` with the non-2xx status, while MCP\nreturns `isError: true` and `_meta['io.happyvertical/smrt']`. Opaque successful\nobjects (including `{ code, message }`) remain untouched; thrown exceptions are\nnot reclassified as domain failures.\n\nCustom route metadata also classifies browser-tool effects. Set `effect` to\n`read`, `write`, or `destructive`, with truthful `idempotent` and `openWorld`\nflags. CRUD classification is fixed: list/get are read, create/update are write,\nand delete is destructive. An undeclared custom action deliberately defaults to\ndestructive, non-idempotent, and open-world so a browser capability policy never\nfails open.\n\nGenerated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.\n\n## Generated dev-plane route (#2782)\n\n`src/vite-plugin/dev-plane-route.ts` emits `<routesDir>/_dev/[...tool]/+server.ts`\nonly when `svelteKit.devPlaneRoute.enabled === true` and\n`@happyvertical/smrt-dev-mcp/dev-plane` resolves from the consumer (same\nresolvability guard as `_resources`). The route is dev-only (`$app/environment`\n`dev`, else 404), refuses to start without `SMRT_DEV_MCP_TOKEN` (503), and\ndelegates everything else to `createDevPlane`, passing `getSmrtConfig(...).db`\nfrom the embedded access prelude (below) so the plane shares the app's\nconnection.\nCore never imports smrt-dev-mcp; only the generated file does, at consumer\nruntime. The route carries the auto-generated header so the sweep owns it.\n\nGenerated SvelteKit write routes (#2977) call `requireRoutePermission(locals, action)` after `requireRouteAuth`: CRUD writes need `<collection>.create|update|delete` and mutating custom actions `<collection>.<method>` in the session permission snapshot on `locals`, else 403 (fail-closed; `api.public: true` stays open, `tenantContext.superAdminBypass` skips). See `packages/users/agents/permissions.md`.\n\n## MCP protocol identifiers (#3219)\n\nMCP discovery preserves existing names matching `[A-Za-z0-9_.-]{1,64}`.\nPreviously invalid names use their first 31 sanitized ASCII characters, `_`,\nand the first 32 hex characters of SHA-256 over the JSON tuple\n`[original class name, lowercased action name, original raw tool name]`.\nThis makes long or punctuation-bearing identifiers deterministic without\ncollapsing common prefixes. Discovery reserves compliant names first and fails\nclosed on any duplicate raw name or final alias collision instead of advertising\nambiguous dispatch (including class names differing only by case across packages).\nConsumers must use the advertised name rather than constructing one.\n\nThe generator retains a separate class/action binding for each descriptor and\nemits that mapping for generated runtimes. Dispatch, task eligibility, tenant\nscoping, ordering, and STI metadata use this binding; protocol aliases are not\nexecution identities. The advertised catalog still gates execution. The direct\nruntime-template API accepts `toolTargets`; omitting it retains the legacy\n`object_action` convention for existing direct callers. Supply explicit targets\nfor canonicalized names or class names containing underscores. This contract\ndoes not change CLI, REST, or WebMCP identifiers.\n\n`MCPGenerator.getToolIdentity(tool)` exposes the generator-validated original\nclass, action, and raw generated name to in-process catalog composers. App MCP\nuses it for class allow-lists, tenant checks, and compatible workflow guards;\nthose consumers must not reverse protocol aliases. The alias remains the sole\nwire tool name and is still the key for public patterns and generic tool policy.\n\n`MCPConfig.classNames` scopes generation (simple names, case-insensitive, or\nqualified `@scope/pkg:Class`); omitted, every registered class is a candidate.\nA simple name matching more than one registered class throws (scope it by\nqualified name). Each tool keeps its class's registry key, and configuration,\nfields, methods, dispatch, tasks and collections resolve through that key —\nnever through the first same-named class. Generated servers (single-file and\nmodular) carry it as `registryKey` in their tool targets and task actions and\nuse it for every emitted collection, policy, class and STI-base lookup;\n`objectName` stays the wire/display name.\nA host with an allow-list passes it so the duplicate-name check runs over the\npublished set only: an unrelated collision among classes it never lists must\nnot fail its catalog or calls (#3490). App MCP always scopes its generators.\n\n## Generated route collection access (#3416)\n\nThere is no generated route-access module. Every generated SvelteKit route\nthat needs collections (CRUD, custom actions, `_changes`, `_events`,\n`sync/apply`, `_dev`) embeds the prelude from\n`generateCollectionAccessImports()` (`sveltekit-config-import.ts`): it imports\nthe generated `smrt-register` module, then the app's config module\n(`$lib/server/smrt` by default) as `* as smrtApplication`, and destructures\n`getCollection`/`getSmrtConfig` from `createGeneratedCollectionAccess()`\n(`src/generated-collection-access.ts`). Generation fails before writing\nanything when `configFileName` resolves to the registration module\n(`smrt-register` with any script extension or case).\nThat helper resolves per call, never caching: a legacy `getCollection` /\n`getSmrtConfig` export wins (deprecated, warned once per module, for one\nrelease), else the exported app-runtime `runtime.getCollection()` /\n`classOptions()`, else it fails with an error naming the module. `_resources`\nand static collection-scoped actions only need registration, so they import\n`smrt-register` and then the config module. When the config module is missing\nand `@happyvertical/smrt-app-runtime/sveltekit` resolves from the consumer, the\ngenerator writes a runtime-shaped default instead of the legacy\n`getCollection` file.\n\n## Enum-constrained fields (#3598)\n\nThe scanner emits `enum: [...]` on a manifest field typed as a TS enum or\nstring/number literal union. Registry-sourced fields mirror it into\n`_meta.enum` (`createFieldFromManifest` / `mergeManifestField`); read it with\n`readFieldEnum()` (top level first, then `_meta`), never directly. Generated\ntool schemas (`fieldTypeToJsonSchema`, so MCP and WebMCP descriptors), OpenAPI\n(`swagger.ts`) and the legacy `generateSchemaProperties` emit JSON Schema\n`enum` for `text`/`integer`/`decimal` fields only; a nullable field appends\n`null` to the list so `type: [.., 'null']` still validates. The client web\ncollection `fields` map does not carry `enum` yet (it feeds the shape digest and\nthree hand-mirrored d.ts files); tool inputs read it from the manifest field.\n"
1191
+ "content": "# smrt-core/code generators\n\nModule semantics for `src/generators/` + `src/vite-plugin/`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Code Generators\n\n| Generator | Location | Output |\n|-----------|----------|--------|\n| REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |\n| MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |\n| Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |\n\nThe same web virtual module exports `webMcpToolDefinitions` (#2518), a\ncanonical per-tool array selected independently of list materialization. Every\nnon-empty canonical API action set contributes tools, so get-only and\ncustom-action-only models are discoverable; custom actions declared on a\n`SmrtCollection` merge into the owning row collection. Each definition carries\ncomplete route and invalidation metadata. `collectionDefinitions` and its\nembedded descriptor copy remain unchanged for existing cache-backed consumers.\n\nGenerated API clients share `selectApiClientEntries()` across the runtime Vite\nmodule, its ambient declaration, and physical prebuild declarations. When a\ncollection class and its populated model share an endpoint, the model owns the\ncanonical collection key and row payload schema; the collection class remains\navailable under a deterministic class-derived secondary key. Selection and\ncollision suffixes must not depend on manifest insertion order (#2027).\nRoute emission iterates manifest objects in the same deterministic\nqualified-identity order (`orderedManifestObjectEntries`), so when two models\nshare one collection the last writer of each route file — and therefore the\nCRUD method set `resolveGeneratedEndpointCrudMethods` mirrors into\n`smrt-client.d.ts` — is stable across scan orders (#2754).\nFor aggregated manifests, inheritance and item-type references resolve exact\nqualified names first, then package-local simple names, then a stable identity\nfallback so duplicate class names across packages cannot reintroduce ordering.\n\nThe web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Four co-managed emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), the physical `@smrt/web` d.ts (`prebuild/index.ts`), and the hand-written type mirror in `@happyvertical/smrt-web` (`packages/smrt-web/src/index.ts` — dependency-free, so textual sync only).\n\n`webMcpToolDefinitions` is deliberately outside that digest: tool-only route,\nidentifier, or annotation changes cannot alter persisted row hydration.\n\nPer-field web emission (#2046): `buildWebFieldDefinitions` carries `description` (from `@field({ description })`) and sanitized `ui` hints (from `@field({ ui: { basic, group, order, locked } })`, read off the manifest `_meta.ui` bag through per-key type guards) into each emitted field definition, and `buildWebToolDescriptors` threads the same `description` into browser MCP tool schemas. `sensitive`/`transient` fields are excluded from emission entirely, so their descriptions never ship. Both keys are conditional, so hint-less schemas emit byte-identical definitions (and hashes) as before; adding a description/ui hint changes the manifest hash — deliberate over-invalidation, harmless per the #1764 contract.\n\n## MCP `get` serializers\n\nA model may add an async serializer only to its standard generated MCP `get`\ntool. Configure a module specifier and named export under `mcp.serializers.get`:\n\n```ts\n@smrt({\n mcp: {\n serializers: {\n get: {\n importPath: '@scope/package',\n exportName: 'serializeThingMcpGet',\n },\n },\n },\n})\n```\n\nThe named export receives `(item, options: PublicJsonOptions)` and returns the\nget result (or a promise for it). Bare package specifiers resolve with ESM\nconditions from the declaring model's loaded module, so the serializer's owning\npackage is an application dependency rather than a dependency of core or a\ngeneric dispatcher. This anchor is independent of `process.cwd()`. Relative\nreferences retain ordinary dynamic-import behavior. The MCP runtime imports\nand caches the named module, then calls the serializer after normal\nauthentication, tenant-scoped lookup, and read access have admitted and loaded\nthe item. A missing or non-function export makes that `get` call fail. The hook\nreplaces only the standard `get` result; generated `list`, writes, deletes, and\ncustom actions keep their existing contracts.\n\nThe serializer owns its public projection. Start with\n`item.toPublicJSON(options)`, and project each related model with that model's\n`toPublicJSON(options)` before adding it. The final transport pass cannot make\narbitrary plain aliases permission-safe: returning a DTO does not cause fields\ninside it to be filtered by model policy. Do not use `toJSON()` or spread model\ninstances into a serializer result.\n\n## Generated MCP server output language\n\n`MCPGenerator` builds every file as TypeScript, so the requested `outputPath`\nextension decides what is written (#2279). `.ts`/`.mts` targets keep the source\nverbatim for `tsx` or Node type stripping — which is why the generated source\nmust stay erasable-syntax-only (no parameter properties, enums, or namespaces).\nEvery other target (`.smrt/mcp-server/index.js` by default) is transpiled to\nJavaScript with lazily loaded `oxc-transform` before writing, because the\nprinted run script and the generated `claude-config.example.json` both invoke\nit with plain `node`. Ordinary core imports and `.ts`/`.mts` output therefore\ndo not load OXC's native bindings.\nThis keeps `typescript` dev-only in `@happyvertical/smrt-core`; generated MCP\nsource must remain erasable-syntax-only. A `.cjs`/`.cts` target is rejected\noutright: generated servers are ES modules. `src/generators/mcp-emit.ts` owns\nthose decisions — do not reintroduce a bare `writeFile` of generated source.\n\nModular output writes `config`, `tools/index`, and `handlers/index` with the\nentry point's own extension, and emits the entry's relative import specifiers\nwith that same extension, so the files it imports both exist and load with the\nsame module semantics — an `.mjs` entry gets `.mjs` siblings, not `.js` ones a\nCommonJS package would then parse as CommonJS. The entry is written at the\nrequested path rather than a hardcoded `index.js`.\nGenerated code also has to be valid in an ES module: `arguments` is not a legal\nbinding name there, however convenient it reads.\n\n## Browser-plane playbook preflight route (#2590)\n\n`GET {basePath}/_preflight?key=<playbook key>` (`src/generators/preflight-route.ts`)\nis an advisory, read-effect, idempotent report of what a caller's playbook would\nbe allowed to do — capability *selection*, never authorization. Resolution and\nverdict shaping live in `@happyvertical/smrt-playbooks`, which depends on this\npackage, so core takes the evaluator as the `APIConfig.playbookPreflight` seam and\nthe dependency stays one-way. Without a provider the route 404s.\n\n**`authMiddleware` is never invoked by preflight**, and that is enforced\nstructurally rather than by discipline: `PlaybookPreflightRouteOptions` has no\nauth member of any kind, and `rest.ts` passes the boolean `appAuthConfigured`\ninstead — so there is no handle in the module to invoke by mistake. A synthetic-\n`Request` dry run is explicitly not an option: the middleware is request-bound,\nreturns a `Response` rather than a boolean, and may consult session stores,\nrate-limit, or audit. The app-auth layer therefore reports `unknown`, which is the\nhonest answer, and a future `authPredicate` seam can fill it in without changing\nthe contract.\n\nThe static layers preflight predicts against are exported from the same module —\n`isApiActionEnabledForObject`, `isRestActionRoutable`, `isRestRoutePublic`,\n`restFieldReadPermissions`, `restMethodForApiAction`,\n`resolveRegisteredObjectName` — and `APIGenerator`'s own\n`isApiActionEnabled` / `isRoutePublic` now delegate to them, so the route and the\nprediction of the route cannot drift. Exposure and existence are separate\nquestions: `include`/`exclude` gate a route, they do not conjure one, so\n`isRestActionRoutable` additionally requires a custom action to be declared in\n`api.routes` — the only map `dispatchCustomCollectionAction` iterates. A custom\naction is predicted against the verb its own route config declares, so a\n`public: 'read'` opt-out neither silently covers a `POST` action nor falsely\ndenies a declared `GET` one. Every unresolvable key returns the provider's single uniform\n\"unavailable\" body with an unconditional 200: unknown and unauthorized keys are\nindistinguishable at the HTTP layer too.\n\n## Emitted agent surface (#2591)\n\nGenerated model tools have always been build-time artifacts — virtual module,\nmanifest, knowledge graph. View intents (#2588) and playbooks (#2589) existed\nonly once something mounted, so \"what can an agent do in this app\" had no answer\nshort of enumerating every route. This closes that.\n\nThe same OXC scan that builds the manifest also runs the scanner's\nagent-surface matcher (`ScanResults.agentSurface`). `smrtPlugin()` captures it\nin `scanWithOxc`, projects it with `toKnowledgeAgentSurface`, and passes it to\n`buildDomainKnowledgeManifest` as `agentSurface`. Note that declaration\ndiscovery is NOT bound to the plugin's `include` glob — an app that scans\n`src/lib/objects/**` for models still has its `src/lib/agent/*.intents.ts`\nsidecars found (see `packages/scanner/AGENTS.md`). Two more consequences worth\nholding onto:\n\n- **It never touches `manifest.json`.** The runtime manifest stays\n runtime-focused; the agent-addressable surface is an agent/developer contract,\n so it lands in `.smrt/smrt-knowledge.json` and `dist/smrt-knowledge.json`\n only, under `agentSurface: { intents, playbooks, diagnostics }`.\n- **It is passed in, not scanned in `knowledge.ts`.** The scanner carries a\n native parser binary and `smrt-core`'s main entry is browser-reachable, so\n core's sync knowledge builder must not import it. The Vite plugin already\n imports the scanner lazily on the Node side and is the only caller that writes\n this artifact.\n\nThe field is **omitted entirely** when a package declares nothing, which is what\nmakes it additive in practice rather than only on paper: every existing\npackage's checked-in artifact stays byte-identical.\n\nEach declaring module gets a `sourceHashes` entry under the\n`agentSurface:<package-relative path>` prefix (`AGENT_SURFACE_HASH_PREFIX`), so\nEDITING an intent sidecar marks the artifact stale exactly like editing\n`AGENTS.md` does (`stale-domain-knowledge`).\n\nHashes alone cannot see an **added** declaration, though: a brand-new sidecar\nhas no recorded hash to mismatch, the runtime manifest never carries intents,\nand `AGENTS.md` is untouched — so every other signal stays green while the\nartifact omits a real operation. `dev:knowledge-check` therefore also re-derives\nthe declaration SET from source and compares it to the artifact by identity,\nreporting either direction as `stale-agent-surface`. The scan is bounded like\nthe numeric-precision lint: `src` only, behind the scanner's token pre-filter.\n\nThat re-derivation must model what the EMITTER sees, not merely what is on\ndisk, or it reports drift no rebuild can clear. Which files count is decided by\nthe scanner's exported `isAgentSurfaceSourcePath` — the same predicate the\nemitter itself uses, never a list copied into the checker — and the per-file\nresults run through `mergeAgentSurfaces` before comparing, because the merge is\nwhere a duplicate identity and a derived tool-name collision are resolved and\nthe artifact is the merged result.\nDiagnostics are compared alongside identities: a sidecar containing only a\ncomputed declaration adds no identity and has no prior hash, so without that,\n\"a diagnostic, never silence\" would quietly become \"a diagnostic, until the\nartifact goes stale\". The walk covers `<pkg>/src` while the emitter globs the\nwhole project root, so an emitted entry from outside `src` is not reported as\nmissing — this check did not look there, and claiming otherwise would be an\nerror nothing could clear.\n\nBoth `stale-*` codes are warnings by default and errors under `--strict`, which\nis what CI runs. Alongside them: `agent-surface-missing-identity`,\n`agent-surface-duplicate-identity`, and `agent-surface-empty-playbook` are\nerrors, and `agent-surface-not-static` and `agent-surface-tool-name-collision`\nare warnings. A cross-file duplicate arrives as a *diagnostic* rather than two\nentries — the scanner's merge already dropped the loser — so that diagnostic\nmaps to the duplicate error rather than the not-static warning; otherwise the\nerror would be unreachable for the case it exists to catch.\n\nA `tool-name-collision` diagnostic (#2725) is derived from the MANIFEST, not\nfrom the declaration: the plugin compares each emitted intent against\n`buildWebMcpToolDefinitions` for the same build, which is already filtered by\nthe exposure policy, and appends the result to the surface. So it is excluded\nfrom the drift comparison above — that pass re-derives declarations from source\nalone and has no manifest, so counting one as \"declared\" would report every\ncollision as drift no rebuild could clear. The check is advisory and drops\nnothing: `defineIntent` accepts the id, so the intent is emitted, and the\ndocument-global tool-name lock (#2613) rejects whichever registers second — the\nbuild-time notice is the earlier, cheaper form of the same answer. A build\ncannot see the provider's WebMCP `namespace` or `effects` policy, either of\nwhich separates the pair outright, so the message states that precondition\nrather than asserting the collision.\n\n`smrt doctor` prints the whole surface — model tools, intents, playbooks — from\nthese artifacts alone, with no application running.\n\n## Custom-action contract\n\n`resolveCustomActionMetadata()` is the common discovery and invocation contract\nfor generated REST routes and API clients, MCP, CLI, WebMCP, and simple\nREST-resource discovery. Receiver scope comes from the executable method, never a\nconfiguration-only `api.routes[name].scope` override: instance model methods\nare item-scoped and require `id`; static model methods and recognized\n`SmrtCollection` methods are collection-scoped and do not accept `id`. Route\nconfiguration may still choose its path and HTTP verb, but it cannot turn an\ninstance call into `ClassRef.action` or vice versa.\n\nWhen scanner method metadata exists, discovery projects each named parameter\nand its JSON-schema type, and invokers pass the values positionally in declared\norder. The legacy single `options` bag remains compatible when metadata is\nabsent (or the declared method takes `options`). Do not infer this from runtime\nfunction arity. An omitted typed `options` parameter remains `undefined`, so a\nmethod's JavaScript default initializer continues to apply; an explicit `null`\nremains `null`. Flat tool and CLI inputs reserve `id` for receiver parsing. If\nan action declares an `id` parameter, its flat MCP/WebMCP field is `actionId`\n(and CLI uses `--action-id`); REST keeps its independent path/body\nnamespaces. Typed CLI actions may use standard flag names such as `limit`,\n`offset`, `where`, and `format` without those values being stripped as CRUD\nflags.\n\nCustom actions may return an explicit, domain-neutral failure object with\n`ok: false`, `code`, and `message` plus optional `status`, `details`,\n`retryable`, and `correlationId`. `normalizeCustomActionFailure()` redacts it;\ngenerated REST returns `{ error: failure }` with the non-2xx status, while MCP\nreturns `isError: true` and `_meta['io.happyvertical/smrt']`. Opaque successful\nobjects (including `{ code, message }`) remain untouched; thrown exceptions are\nnot reclassified as domain failures.\n\nCustom route metadata also classifies browser-tool effects. Set `effect` to\n`read`, `write`, or `destructive`, with truthful `idempotent` and `openWorld`\nflags. CRUD classification is fixed: list/get are read, create/update are write,\nand delete is destructive. An undeclared custom action deliberately defaults to\ndestructive, non-idempotent, and open-world so a browser capability policy never\nfails open.\n\nGenerated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.\n\n## Generated dev-plane route (#2782)\n\n`src/vite-plugin/dev-plane-route.ts` emits `<routesDir>/_dev/[...tool]/+server.ts`\nonly when `svelteKit.devPlaneRoute.enabled === true` and\n`@happyvertical/smrt-dev-mcp/dev-plane` resolves from the consumer (same\nresolvability guard as `_resources`). The route is dev-only (`$app/environment`\n`dev`, else 404), refuses to start without `SMRT_DEV_MCP_TOKEN` (503), and\ndelegates everything else to `createDevPlane`, passing `getSmrtConfig(...).db`\nfrom the embedded access prelude (below) so the plane shares the app's\nconnection.\nCore never imports smrt-dev-mcp; only the generated file does, at consumer\nruntime. The route carries the auto-generated header so the sweep owns it.\n\nGenerated SvelteKit write routes (#2977) call `requireRoutePermission(locals, action)` after `requireRouteAuth`: CRUD writes need `<collection>.create|update|delete` and mutating custom actions `<collection>.<method>` in the session permission snapshot on `locals`, else 403 (fail-closed; `api.public: true` stays open, `tenantContext.superAdminBypass` skips). See `packages/users/agents/permissions.md`.\n\n## MCP protocol identifiers (#3219)\n\nMCP discovery preserves existing names matching `[A-Za-z0-9_.-]{1,64}`.\nPreviously invalid names use their first 31 sanitized ASCII characters, `_`,\nand the first 32 hex characters of SHA-256 over the JSON tuple\n`[original class name, lowercased action name, original raw tool name]`.\nThis makes long or punctuation-bearing identifiers deterministic without\ncollapsing common prefixes. Discovery reserves compliant names first and fails\nclosed on any duplicate raw name or final alias collision instead of advertising\nambiguous dispatch (including class names differing only by case across packages).\nConsumers must use the advertised name rather than constructing one.\n\nThe generator retains a separate class/action binding for each descriptor and\nemits that mapping for generated runtimes. Dispatch, task eligibility, tenant\nscoping, ordering, and STI metadata use this binding; protocol aliases are not\nexecution identities. The advertised catalog still gates execution. The direct\nruntime-template API accepts `toolTargets`; omitting it retains the legacy\n`object_action` convention for existing direct callers. Supply explicit targets\nfor canonicalized names or class names containing underscores. This contract\ndoes not change CLI, REST, or WebMCP identifiers.\n\n`MCPGenerator.getToolIdentity(tool)` exposes the generator-validated original\nclass, action, and raw generated name to in-process catalog composers. App MCP\nuses it for class allow-lists, tenant checks, and compatible workflow guards;\nthose consumers must not reverse protocol aliases. The alias remains the sole\nwire tool name and is still the key for public patterns and generic tool policy.\n\n`MCPConfig.classNames` scopes generation (simple names, case-insensitive, or\nqualified `@scope/pkg:Class`); omitted, every registered class is a candidate.\nA simple name matching more than one registered class throws (scope it by\nqualified name). Each tool keeps its class's registry key, and configuration,\nfields, methods, dispatch, tasks and collections resolve through that key —\nnever through the first same-named class. Generated servers (single-file and\nmodular) carry it as `registryKey` in their tool targets and task actions and\nuse it for every emitted collection, policy, class and STI-base lookup;\n`objectName` stays the wire/display name.\nA host with an allow-list passes it so the duplicate-name check runs over the\npublished set only: an unrelated collision among classes it never lists must\nnot fail its catalog or calls (#3490). App MCP always scopes its generators.\n\n## Generated route collection access (#3416)\n\nThere is no generated route-access module. Every generated SvelteKit route\nthat needs collections (CRUD, custom actions, `_changes`, `_events`,\n`sync/apply`, `_dev`) embeds the prelude from\n`generateCollectionAccessImports()` (`sveltekit-config-import.ts`): it imports\nthe generated `smrt-register` module, then the app's config module\n(`$lib/server/smrt` by default) as `* as smrtApplication`, and destructures\n`getCollection`/`getSmrtConfig` from `createGeneratedCollectionAccess()`\n(`src/generated-collection-access.ts`). Generation fails before writing\nanything when `configFileName` resolves to the registration module\n(`smrt-register` with any script extension or case).\nThat helper resolves per call, never caching: a legacy `getCollection` /\n`getSmrtConfig` export wins (deprecated, warned once per module, for one\nrelease), else the exported app-runtime `runtime.getCollection()` /\n`classOptions()`, else it fails with an error naming the module. `_resources`\nand static collection-scoped actions only need registration, so they import\n`smrt-register` and then the config module. When the config module is missing\nand `@happyvertical/smrt-app-runtime/sveltekit` resolves from the consumer, the\ngenerator writes a runtime-shaped default instead of the legacy\n`getCollection` file.\n\n## Enum-constrained fields (#3598)\n\nThe scanner emits `enum: [...]` on a manifest field typed as a TS enum or\nstring/number literal union. Registry-sourced fields mirror it into\n`_meta.enum` (`createFieldFromManifest` / `mergeManifestField`); read it with\n`readFieldEnum()` (top level first, then `_meta`), never directly. Generated\ntool schemas (`fieldTypeToJsonSchema`, so MCP and WebMCP descriptors), OpenAPI\n(`swagger.ts`) and the legacy `generateSchemaProperties` emit JSON Schema\n`enum` for `text`/`integer`/`decimal` fields only; a nullable field appends\n`null` to the list so `type: [.., 'null']` still validates. The client web\ncollection `fields` map does not carry `enum` yet (it feeds the shape digest and\nthree hand-mirrored d.ts files); tool inputs read it from the manifest field.\n\n## Custom-action permission aliases (#3878)\n\n`@smrt({ operationPermissions: { run: { action: 'read' } } })` lets a custom\nmethod reuse an existing CRUD grant in its own collection. Generated SvelteKit\nPOST routes and smrt-users operation guards resolve the same mapping; the\ncatalog emits the target grant rather than a second method-named permission.\nCRUD itself cannot be aliased. Malformed declarations fail closed. Optional\n`databaseActions: ['SELECT', 'UPDATE']` adds explicit PostgreSQL policy bindings\nfor the aliased grant (for example a status-only archive guarded by `create`\nthat must load its item before updating it). This is server-authored authority\nmetadata: grant only the actions the method needs, review it with the method's\nmodel-level validation, and regenerate RLS policies when adopting a changed\nbinding.\n\nCustom actions retain the `{ action, result }` response envelope.\n"
1185
1192
  },
1186
1193
  {
1187
1194
  "path": "agents/build-knowledge.md",
@@ -1216,7 +1223,7 @@
1216
1223
  {
1217
1224
  "path": "agents/recipes.md",
1218
1225
  "module": "recipes",
1219
- "content": "# SmrtRecipe: declared units of app functionality (#3590)\n\nA **recipe** is a small, named, user-facing unit (\"Sales\", \"Customers\"),\nsmaller than a package. An app or agent adds a recipe and gets that section:\nnavigation, list and create/edit views, and its prerequisites, without every\nmodel in the owning package. Source: `src/recipe.ts` (authoring base class),\n`packages/scanner/src/recipes.ts` (matcher and validation), types in\n`@happyvertical/smrt-types` (`RecipeDefinition`).\n\n## Authoring\n\n`SmrtRecipe` is an abstract, **non-persisted, non-`@smrt()`** base class\nexported from both core entries (`index.ts` and `browser.ts`). A recipe is a\nsubclass with static properties, in the package that owns its models:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n static id = 'commerce.sales'; // dotted lowercase, unique\n static label = 'Sales';\n static summary = 'Take customer orders and track them.';\n static synonyms = ['sales orders', 'orders'];\n static models = [Order]; // class references\n static nav = [{ label: 'Sales Orders', model: Order }];\n static requires = ['commerce.customers']; // recipe ids, any package\n static options = { Order: { fields: { status: { default: 'draft' } } } };\n}\n```\n\nStatics are read **structurally** (nothing is evaluated), so each must be\nspelled literally; `models` and `nav[].model` take identifiers. Write\n`static options = { ... } as const` so `visibility` stays a literal type.\nThe emitted `options` are keyed by qualified model name.\n\n## Host metadata: group, section, nav detail, alternatives (#3604)\n\nEverything a catalog host (an app shell, an agent, a CLI) needs\nto present a recipe lives on the recipe, so it reads the manifest or knowledge\nartifact instead of importing runtime code. All are optional and additive:\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `group` | `{ id, label, summary? }` | Recipes with the same `group.id` share one card; the first declaration of an id supplies `label` and `summary`. Declaration order is the sub-switch order. |\n| `section` | `{ id, label, icon?, description? }` | Navigation section the recipe suggests for its `nav` entries. The host owns sections and the user may rename them, so keep `id` stable; the same `id` shares a section. |\n| `requiresAny` | `string[][]` | At least one id of each inner list must be on (`[['products.simple', 'products.clothing']]`); adding the recipe with none on adds the first. |\n| `nav[].icon` / `description` | string | Shell icon name and one friendly line for a business owner. The icon must be a key of `SHELL_ICON_PATHS` (smrt-svelte `admin-shell/shell-icons.ts`, camelCase such as `fileText` or `shoppingBag`); the same holds for `section.icon`. `packages/scanner/src/__tests__/workspace-recipes.test.ts` fails on any other name. |\n| `nav[].noun` | string | What \"New\" creates when the label is not countable (\"Stock levels\" -> `stock entry`). |\n| `nav[].key` | slug | Fixed key for the layout id `item:<pkg>:<Model>:<key>`. Required for a second entry over the same model and for a `filter`. |\n| `nav[].filter` | `{ field, value }` | Narrows the entry to rows where `field` equals `value` (Ingredients over Products: `productType` = `material`). Needs a `key`. |\n| `options.<Model>.fields.<f>.required` | boolean | The form refuses to save without a value. |\n\nAcross recipes of one package the scanner also rejects a repeated nav `key`\nover the same model (it names one layout id) and one `group`/`section` id with\ntwo different labels. `packages/scanner/src/__tests__/workspace-recipes.test.ts`\nscans every recipe in the workspace and checks ids, `requires`/`requiresAny`\ntargets (which a single package cannot see) and shared labels.\n\nChild models (line items) are simply listed in `models` without a `nav` entry.\nValidation: `group`/`section` take only their keys, slug ids, non-empty\nstrings; `requiresAny` ids follow the recipe-id format and cannot name the\nrecipe itself; each (model, key) pair appears once in `nav`; a `filter.field`\nmust be a field the model declares (checked in core on the merged manifest,\n`assertRecipeOptions`).\n\nNot part of the recipe declaration (still host-local): a host's\ncross-package `forms` and `extends` (form records spanning profiles, variants\nand SKUs).\n\nUser-facing field text for the recipes comes from `@field({ description })` on\nthe models (the glossary seed), so a package that declares a recipe carries the\ndescriptions of every field the recipe shows. A field the package does not\ndeclare itself (`parentId` from `SmrtHierarchical`) is described by the recipe's\n`options.<Model>.fields.<f>.help`, which wins over the description in the\nglossary. Write all of it in plain words: no API, JSON, REST, MCP, SQL or UUID\noutside help that is about connecting other tools.\n\n## Non-model surfaces, providers, runtime, demo seed (#3708)\n\nMany features are not a list of models: a shell widget (assistant dock,\nnotification bell), a component, a route, a settings panel. All four statics are\noptional and additive; a recipe that declares none emits none of the keys. They\nare emitted on the recipe entry in `manifest.json` and `smrt-knowledge.json`.\nComponents are named by `'<package specifier>#<ExportName>'` and never imported\nat scan time; a host resolves them. Relative or absolute specifiers are rejected.\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `surfaces` | `RecipeSurface[]` | `{ kind: 'shell-widget', slot, export, label, icon? }`, `{ kind: 'route', path, export, label }`, `{ kind: 'settings-panel', export, label }`, `{ kind: 'playground', export, label? }`, `{ kind: 'widget', type, export, label, ... }` (see below). `slot` is a smrt-svelte `ShellSlot`; `path` starts with `/` and has no whitespace, `?`, `#` or `..`. |\n| `providers` | `RecipeProvider[]` | `{ id, kind, options, required, secrets?, browserOptions? }`: lowercase slugs, `options` non-empty and distinct, `required` always written, `secrets` distinct `UPPER_SNAKE` names (never values), `browserOptions` a non-empty subset of `options` that run in a browser with none of the secrets (#3709). Ids are unique per recipe. |\n| `runtime` | `'browser' \\| 'server' \\| 'both'` | Where the recipe's runtime pieces can run. Omitted means `both`; the value is emitted as authored. The build refines it into `demo` (below). |\n| `demoSeed` | `{ export } \\| { data }` | A fixture export reference, or inline JSON of at most 8 KB, for demo hosts. Exactly one key. |\n\nWrite `surfaces` with `as const` (like `options`) so `kind` and `slot` stay\nliteral types. The slot union is `RecipeShellSlot` in smrt-types (types only) and the\nscanner's `RECIPE_SHELL_SLOTS` list copies smrt-svelte's `SHELL_SLOTS`; a\ntype-level test in smrt-svelte and a scanner test keep them equal, so there is\nno runtime dependency on smrt-svelte. Inline `demoSeed.data` must be finite JSON. Every violation (unknown\nkind or slot, extra key, duplicate route path or provider id, malformed\nreference) is a scan error. Example: `events.calendar` in\n`packages/events/src/recipes.ts`.\n\n## Widget surfaces (#3727)\n\n`{ kind: 'widget', ... }` contributes a widget to customizable overviews\n(smrt-svelte `./overview`). The manifest carries everything a host needs to\nregister it; the helper `registerRecipeWidgets` in smrt-svelte does that with an\ninjected module resolver, so nothing is imported at scan time.\n\n```ts\nstatic surfaces = [\n {\n kind: 'widget',\n type: 'sales-total', // registry type, unique per package\n export: '@acme/shop/svelte#SalesTotal', // the component\n label: 'Sales total', // text or an i18n key\n description: 'Order totals for the period.',\n icon: 'chart',\n version: 2, // option-schema version (default 1)\n migrate: '@acme/shop/server#migrateSalesTotal', // needs version above 1\n options: [\n { key: 'model', type: 'model', label: 'Model', required: true },\n { key: 'style', type: 'enum', label: 'Style', default: 'bar',\n choices: [{ value: 'bar', label: 'Bar' }, { value: 'line', label: 'Line' }] },\n ],\n data: { load: '@acme/shop/server#loadSalesTotal', models: ['@acme/shop:Order'] },\n allowedIn: ['shop.home'], // overview ids; omit for any\n defaultSpan: 2, minSpan: 1, maxSpan: 3,\n },\n] as const;\n```\n\nEmitted keys, in this order: `kind`, `type`, `export`, `label`, `description`,\n`icon`, `version`, `migrate`, `options`, `data`, `allowedIn`, `defaultSpan`,\n`minSpan`, `maxSpan`; absent ones are omitted. Rules (each violation is a scan\nerror, same strictness as the other kinds):\n\n- `type` is a lowercase kebab id (`[a-z][a-z0-9-]{0,31}`), unique among a\n recipe's surfaces and across the package's recipes. `export`, `data.load` and\n `migrate` are `'<package specifier>#<ExportName>'`; relative specifiers are\n rejected. `label` is required.\n- `options` is a non-empty list of plain-JSON fields limited to the overview\n option types: `text`, `markdown`, `identifier`, `model`, `integer`, `number`,\n `boolean`, closed `enum` (non-empty distinct `choices`). There is no free-form\n query, expression, URL or SQL type. Keys are camelCase (`[A-Za-z][A-Za-z0-9]{0,31}`)\n and unique; `default` must pass its field's own rule; `min`/`max` only on\n numbers, `maxLength` only on text. Only `key`, `type`, `label`, `help`,\n `required`, `default`, `min`, `max`, `maxLength`, `choices` are accepted.\n- `data` takes `load` and/or `models` (qualified class names, distinct). The\n scanner cannot see other packages, so a model is checked for format only.\n- `allowedIn` is a non-empty list of distinct overview ids\n (`[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}`); spans are integers 1 to 4 with\n `minSpan <= defaultSpan <= maxSpan`.\n\nThe scanner's copies of these rules live in `packages/scanner/src/recipe-widgets.ts`\nand a scanner test compares them to the smrt-svelte source. See\n[overview-surfaces.md](../../smrt-svelte/agents/overview-surfaces.md#recipe-widgets-recipe-widgetsts)\nfor how a host registers them.\n\n## Browser-demo mode: Live, Mock, Sample, Server (#3709)\n\nA catalog host (smrt-planner) labels each feature by how far it runs in a\nbrowser-only demo. The label is **derived at build time**, never authored: the\nmanifest build combines what the bundle-gate measured for the recipe's package\nwith the recipe's own `runtime`, `providers` and `demoSeed`, and emits it as\n`demo` on the recipe entry and `browser` on the package. A recipe that declares\nnone of those keeps its shape except for `demo`, which every recipe of a\nmeasured package gets.\n\n```jsonc\n// manifest.json and smrt-knowledge.json, top level\n\"browser\": {\n \"status\": \"browser-safe\" | \"server-only\",\n \"issues\": [\"#3624\"], // tracking issues; omitted when browser-safe\n \"reason\": \"node:crypto\", // the package's own blocker; omitted when only inherited\n \"via\": [\"@happyvertical/smrt-tenancy\"] // inherited from these; omitted when none\n}\n// on each entry of \"recipes\"\n\"demo\": {\n \"mode\": \"live\" | \"mock\" | \"sample\" | \"server\",\n \"reasons\": [\"Needs a server because ...\"], // plain sentences, in rule order\n \"mocked\": [\"smtp\"] // providers a demo fakes; omitted when none\n}\n```\n\n| Mode | Meaning |\n| --- | --- |\n| `live` | Real browser data layer (PGlite); nothing faked. |\n| `mock` | Runs in the browser, but a provider (sending mail, OAuth) is faked. |\n| `sample` | Runs in the browser on `demoSeed` fixtures only. |\n| `server` | Needs a server; a browser-only host cannot offer it. |\n\n**Package capability.** The browser gate (`packages/bundle-gate`) fails any\nmodel package that is not in its expected-failures list and does not build for\na browser, and any listed package that now builds. So a package is `server-only`\nexactly when it is listed. The ratchet attributes a defect to the package that\nowns the fix, so a dependent passes while still dragging the failure into a\nbrowser bundle; the derivation therefore also marks a package `server-only`\nwhen a workspace package it depends on (transitively, through `dependencies`)\nis, and records them in `via`. `smrt-core` is the platform baseline and does not\npropagate. The table lives in `src/manifest/browser-capability.generated.ts`\n(generated, never edited) and a bundle-gate spec fails when it is stale; see\nthat package's AGENTS.md for the regenerate command. A package the gate does\nnot measure (an app's own, a UI package) emits no `browser`, and its recipes get\na `demo` only when they declare `runtime`, `providers` or `demoSeed`.\n\n**Rules** (`src/recipe-demo.ts`, pure and browser-safe; the worst mode wins,\n`live` < `mock` < `sample` < `server`):\n\n1. `runtime: 'server'` is `server`. `runtime: 'both'` or omitted makes no claim:\n the package capability refines it, so `both` on a `server-only` package is\n `server`.\n2. A `server-only` package is `server`.\n3. A **server provider** is one that lists `secrets` (credentials a browser\n cannot hold). One with a `browserOptions` entry (an in-browser model such as\n `webllm`) is satisfied in a browser and changes nothing. Otherwise it is\n **mockable** when its `options` include `mock`. A *required* server provider\n that is neither is `sample` when the recipe has a `demoSeed` (the fixtures\n stand in for what the provider would feed), else `server`. An optional one\n that is neither leaves the mode alone and adds a reason.\n4. Any mockable server provider makes the recipe `mock` and lands in `mocked`.\n5. Otherwise `live`.\n\n**Contradictions fail the build** (like `assertRecipeOptions`), naming the\nrecipe: `runtime: 'browser'` on a `server-only` package, or beside a required\nserver provider with no in-browser and no `mock` option. Fix by giving the\nprovider a `browserOptions` entry or a `mock` option, making it optional, or\ndeclaring `both`/`server`. An explicit `both` or\n`server` on a `server-only` package is not a contradiction.\n\n**Dependencies between recipes.** `demo` is the recipe's own answer. A host that\nwants the answer including what the recipe needs calls\n`effectiveRecipeDemo(id, recipes)` (exported from both core entries): the worst\nof its own mode and every `requires` (transitively), counting the best known\nalternative of each `requiresAny` list; unknown ids are ignored.\n\n**Importing it in a browser page.** Use the subpath\n`@happyvertical/smrt-core/recipe-demo` (`deriveRecipeDemo`,\n`effectiveRecipeDemo`, `RECIPE_DEMO_MODES` and the demo types). Its module graph\nis `src/recipe-demo.ts` alone, with type-only imports, so it carries no `node:`\nimport. The root entry and `/browser` both also export these names, but both\nreach `manifest/manifest-loader.ts` through the registry, and that module calls\n`createRequire` from `node:module` as it loads, which a browser page cannot\nsatisfy. `src/__tests__/recipe-demo-entry.test.ts` walks the module graph and\nfails if the subpath gains a `node:` or package import.\n\nThe classification runs in `ManifestGenerator.applyGenerationPasses` (the one\npass sequence the Vite plugin, `ManifestBuilder` and `generateManifest()` share)\nthrough `applyRecipeDemo` in `src/scanner/recipe-demo-pass.ts`, after the\nmerged-manifest recipe assertions. It needs the package name, so it cannot run\nin the scanner, which resolves recipes before it knows the package.\n\n## How the scanner collects it\n\nThe scanner class pass finds classes by decorator, which a recipe lacks, so\n`recipes.ts` is a second matcher in the style of the agent-surface one: a class\nwhose `extends` resolves to the `SmrtRecipe` import binding from\n`@happyvertical/smrt-core` (named, aliased `SmrtRecipe as R`, or namespace\n`Core.SmrtRecipe`; any core subpath such as `/browser`). A same-named local\nclass or an import from another library is not a recipe. A recipe must extend\n`SmrtRecipe` **directly**. Within one file, extending another recipe or declaring\none as a class expression or inside a block is a scan error rather than a silent\ndrop. Across files it is not detected: a module that never names `SmrtRecipe`\nis not read, so a recipe extending a recipe or intermediate base imported from\nanother module is unsupported and would be missing from the artifacts.\n\nClass references resolve to qualified names (`@scope/pkg:Class`) through the\nimport binding: a relative specifier picks the scanned class by file, then by\nclass name (barrel re-exports keep the name); an aliased import follows the\n*imported* name; `Ns.Class` namespace members and same-file classes work; a\nbare package specifier is rejected (models must belong to this package). When\nthe imported file declares no such class, the barrel fallback binds a same-named\nscanned class, so keep model class names unique within a package. This\nwas chosen over qualified-name strings because it type-checks and survives\nrenames; strings were not needed.\n\nRecipe discovery is independent of the model `include` glob (like intents; it is\nnot gated by the `agentSurface` option), and\n`ScanResults.recipes` is populated by `resolve()`. Every error is a scan error,\nso every producer that reads `scanAndResolve()` (`smrtPlugin`, `ManifestBuilder`)\nfails the build. (The legacy `ManifestGenerator.generateManifest()` path takes\npre-scanned results and never sees recipes.) The adapter qualifies names and emits a top-level `recipes` array in\n`manifest.json`; `buildDomainKnowledgeManifest` projects the same array into\n`smrt-knowledge.json`. A package with no recipe emits no key.\n\n## Build-time validation\n\nIds are dotted lowercase and unique; `models` is non-empty, resolvable, and\nduplicate-free; every `nav` model is in `models`; `requires` are well-formed,\nnot self-referential or duplicated, and acyclic within the package (ids in other\npackages cannot be checked at scan time); `options` name only listed models and\nand only the keys below. Field names are checked in core\n(`ManifestGenerator.assertRecipeOptions`, after inherited fields merge) against\nthe merged manifest, so STI-merged, tenant, and framework-base fields such as\n`parentId` are accepted along with the universal `id`, `slug`, `context`,\n`created_at`, `updated_at`; anything else fails the build.\n\n## options: curation hints, never a second schema\n\nKeyed by model class name. `fields.<name>` accepts the smrt-fields policy\nvocabulary: `default`, `label`, `help`, `order`, `visibility`\n(`basic | advanced | hidden`), `locked`; applying a recipe produces\nfield-policy rows. `exposure.<api|mcp|cli>` accepts only `false` or\nnon-empty `{ exclude: [...] }` naming a CRUD verb (`list`, `get`, `create`,\n`update`, `delete`) or a public instance method the model declares or inherits: `true` and `include` are\nrejected because they can widen what the model already declares. Custom fields are out of scope.\n\n## User-facing help (#3591)\n\nHelp is documentation for the people using the app, not developers. It lives\nbeside the recipe as Markdown and is pointed to by `static help`:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n // ...\n static help = './sales.recipe.md'; // relative to the recipe file, .md only\n}\n```\n\nThe file holds an **Overview** and **Tasks** (short how-tos). A step names a\nfield as `{field:<fieldName>}` or `{field:<Model>.<fieldName>}`; the Markdown\nblock (heading, paragraph, or list item) containing a reference is **tied to\nthat field**. Keep prose in the separate file rather than the recipe class.\nField text itself comes from `@field({ description })`, the user-facing seed\nsmrt-fields uses for form help; every field a recipe shows should carry one.\n\n**Emission.** The scanner reads the file (a missing, empty, non-`.md`, absolute\nor `..`-climbing path is a scan error) and emits\n`help: { markdown, fieldRefs }` on the recipe entry in `manifest.json` and\n`smrt-knowledge.json`, so a static host needs no further request.\n`fieldRefs` (distinct references as written, sorted) is **derived**, never\nauthored. Knowledge fields also carry `description` (sensitive fields stay\nexcluded). `ManifestGenerator.assertRecipeHelp` fails the build on a reference\nto a field the recipe's models do not declare, and on a `fieldRefs` list that\ndisagrees with the Markdown. The scanner has its own copy of the reference\ngrammar (`deriveHelpFieldRefs`; it cannot import core), so that comparison also\nkeeps the two from drifting. A reference to a **sensitive** or universal (`id`,\n`slug`, ...) field also fails the build: neither reaches the knowledge artifact,\nso a host building its field list from it would silently drop the step naming\none; describe such a field in prose without a reference.\n\nKnown limitation: the help file is a generator input, but editing only it does\nnot trigger a dev-server rescan (the watch matches source globs) and generation\nsnapshots do not digest it, so rebuild (or touch a `.ts` source) after editing.\n\n**Rendering.** `src/recipe-help.ts` is pure and browser-safe (exported from\nboth core entries). `renderHelp(help, models, options?)` takes the recipe's\nhelp plus the app's effective field policies (`HelpModel[]`; label, help,\nvisibility, disabled in smrt-fields vocabulary, with `@field` descriptions) and\nreturns `{ blocks, glossary, connect? }`:\n\n- `{field:x}` becomes the effective label;\n- a block tied to a hidden, disabled, undeclared, or **advanced** field (unless\n `showAdvanced`) is dropped; a list item drops alone; a heading whose section\n had content that was all dropped is pruned (a heading that never had content\n stays);\n- the glossary lists each shown field with its effective help, else its\n description. A shown field with neither is **skipped**, not listed blank;\n- `connect` is the collapsed \"Connect other tools\" section: the recipe's\n generated REST routes, MCP tools and CLI commands (`options.surfaces`, from\n the knowledge artifact), minus the recipe's `exposure` narrowing\n (`options.exposure`). Hosts render it last.\n\nThe result is an AST of text nodes (`text`, `code`, `strong`, `em`), never HTML,\nso authored markup stays inert text and nothing needs sanitizing. Only\n`##`-`####` headings, paragraphs, flat lists and bold/italic/code are\nunderstood. `helpToMarkdown(rendered)` flattens it for hosts with their own\nrenderer; render that with raw HTML disabled. `validateHelp(help, models)`\nreturns the build-failure problems for hand-built entries.\n"
1226
+ "content": "# SmrtRecipe: declared units of app functionality (#3590)\n\nA **recipe** is a small, named, user-facing unit (\"Sales\", \"Customers\"),\nsmaller than a package. An app or agent adds a recipe and gets that section:\nnavigation, list and create/edit views, and its prerequisites, without every\nmodel in the owning package. Source: `src/recipe.ts` (authoring base class),\n`packages/scanner/src/recipes.ts` (matcher and validation), types in\n`@happyvertical/smrt-types` (`RecipeDefinition`).\n\n## Authoring\n\n`SmrtRecipe` is an abstract, **non-persisted, non-`@smrt()`** base class\nexported from both core entries (`index.ts` and `browser.ts`). A recipe is a\nsubclass with static properties, in the package that owns its models:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n static id = 'commerce.sales'; // dotted lowercase, unique\n static label = 'Sales';\n static summary = 'Take customer orders and track them.';\n static synonyms = ['sales orders', 'orders'];\n static models = [Order]; // class references\n static nav = [{ label: 'Sales Orders', model: Order }];\n static requires = ['commerce.customers']; // recipe ids, any package\n static options = { Order: { fields: { status: { default: 'draft' } } } };\n}\n```\n\nStatics are read **structurally** (nothing is evaluated), so each must be\nspelled literally; `models` and `nav[].model` take identifiers. Write\n`static options = { ... } as const` so `visibility` stays a literal type.\nThe emitted `options` are keyed by qualified model name.\n\n## Host metadata: group, section, nav detail, alternatives (#3604)\n\nEverything a catalog host (an app shell, an agent, a CLI) needs\nto present a recipe lives on the recipe, so it reads the manifest or knowledge\nartifact instead of importing runtime code. All are optional and additive:\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `group` | `{ id, label, summary? }` | Recipes with the same `group.id` share one card; the first declaration of an id supplies `label` and `summary`. Declaration order is the sub-switch order. |\n| `section` | `{ id, label, icon?, description? }` | Navigation section the recipe suggests for its `nav` entries. The host owns sections and the user may rename them, so keep `id` stable; the same `id` shares a section. |\n| `requiresAny` | `string[][]` | At least one id of each inner list must be on (`[['products.simple', 'products.clothing']]`); adding the recipe with none on adds the first. |\n| `nav[].icon` / `description` | string | Shell icon name and one friendly line for a business owner. The icon must be a key of `SHELL_ICON_PATHS` (smrt-svelte `admin-shell/shell-icons.ts`, camelCase such as `fileText` or `shoppingBag`); the same holds for `section.icon`. `packages/scanner/src/__tests__/workspace-recipes.test.ts` fails on any other name. |\n| `nav[].noun` | string | What \"New\" creates when the label is not countable (\"Stock levels\" -> `stock entry`). |\n| `nav[].key` | slug | Fixed key for the layout id `item:<pkg>:<Model>:<key>`. Required for a second entry over the same model and for a `filter`. |\n| `nav[].filter` | `{ field, value }` | Narrows the entry to rows where `field` equals `value` (Ingredients over Products: `productType` = `material`). Needs a `key`. |\n| `options.<Model>.fields.<f>.required` | boolean | The form refuses to save without a value. |\n\nAcross recipes of one package the scanner also rejects a repeated nav `key`\nover the same model (it names one layout id) and one `group`/`section` id with\ntwo different labels. `packages/scanner/src/__tests__/workspace-recipes.test.ts`\nscans every recipe in the workspace and checks ids, `requires`/`requiresAny`\ntargets (which a single package cannot see) and shared labels.\n\nChild models (line items) are simply listed in `models` without a `nav` entry.\nValidation: `group`/`section` take only their keys, slug ids, non-empty\nstrings; `requiresAny` ids follow the recipe-id format and cannot name the\nrecipe itself; each (model, key) pair appears once in `nav`; a `filter.field`\nmust be a field the model declares (checked in core on the merged manifest,\n`assertRecipeOptions`).\n\nNot part of the recipe declaration (still host-local): a host's\ncross-package `forms` and `extends` (form records spanning profiles, variants\nand SKUs).\n\nUser-facing field text for the recipes comes from `@field({ description })` on\nthe models (the glossary seed), so a package that declares a recipe carries the\ndescriptions of every field the recipe shows. A field the package does not\ndeclare itself (`parentId` from `SmrtHierarchical`) is described by the recipe's\n`options.<Model>.fields.<f>.help`, which wins over the description in the\nglossary. Write all of it in plain words: no API, JSON, REST, MCP, SQL or UUID\noutside help that is about connecting other tools.\n\n## Non-model surfaces, providers, runtime, demo seed (#3708)\n\nMany features are not a list of models: a shell widget (assistant dock,\nnotification bell), a component, a route, a settings panel. All four statics are\noptional and additive; a recipe that declares none emits none of the keys. They\nare emitted on the recipe entry in `manifest.json` and `smrt-knowledge.json`.\nComponents are named by `'<package specifier>#<ExportName>'` and never imported\nat scan time; a host resolves them. Relative or absolute specifiers are rejected.\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `surfaces` | `RecipeSurface[]` | `{ kind: 'shell-widget', slot, export, label, icon? }`, `{ kind: 'route', path, export, label }`, `{ kind: 'settings-panel', export, label }`, `{ kind: 'playground', export, label? }`, `{ kind: 'widget', type, export, label, ... }` (see below). `slot` is a smrt-svelte `ShellSlot`; `path` starts with `/` and has no whitespace, `?`, `#` or `..`. |\n| `providers` | `RecipeProvider[]` | `{ id, kind, options, required, secrets?, browserOptions? }`: lowercase slugs, `options` non-empty and distinct, `required` always written, `secrets` distinct `UPPER_SNAKE` names (never values), `browserOptions` a non-empty subset of `options` that run in a browser with none of the secrets (#3709). Ids are unique per recipe. |\n| `runtime` | `'browser' \\| 'server' \\| 'both'` | Where the recipe's runtime pieces can run. Omitted means `both`; the value is emitted as authored. The build refines it into `demo` (below). |\n| `demoSeed` | `{ export } \\| { data }` | A fixture export reference, or inline JSON of at most 8 KB, for demo hosts. Exactly one key. |\n\nWrite `surfaces` with `as const` (like `options`) so `kind` and `slot` stay\nliteral types. The slot union is `RecipeShellSlot` in smrt-types (types only) and the\nscanner's `RECIPE_SHELL_SLOTS` list copies smrt-svelte's `SHELL_SLOTS`; a\ntype-level test in smrt-svelte and a scanner test keep them equal, so there is\nno runtime dependency on smrt-svelte. Inline `demoSeed.data` must be finite JSON. Every violation (unknown\nkind or slot, extra key, duplicate route path or provider id, malformed\nreference) is a scan error. Example: `events.calendar` in\n`packages/events/src/recipes.ts`.\n\n## Widget surfaces (#3727)\n\n`{ kind: 'widget', ... }` contributes a widget to customizable overviews\n(smrt-svelte `./overview`). The manifest carries everything a host needs to\nregister it; the helper `registerRecipeWidgets` in smrt-svelte does that with an\ninjected module resolver, so nothing is imported at scan time.\n\n```ts\nstatic surfaces = [\n {\n kind: 'widget',\n type: 'sales-total', // registry type, unique per package\n export: '@acme/shop/svelte#SalesTotal', // the component\n label: 'Sales total', // text or an i18n key\n description: 'Order totals for the period.',\n icon: 'chart',\n version: 2, // option-schema version (default 1)\n migrate: '@acme/shop/server#migrateSalesTotal', // needs version above 1\n optionsEditor: '@acme/shop/svelte#SalesOptions', // optional dependent-choice editor\n options: [\n { key: 'model', type: 'model', label: 'Model', required: true },\n { key: 'style', type: 'enum', label: 'Style', default: 'bar',\n choices: [{ value: 'bar', label: 'Bar' }, { value: 'line', label: 'Line' }] },\n ],\n data: { load: '@acme/shop/server#loadSalesTotal', models: ['@acme/shop:Order'] },\n allowedIn: ['shop.home'], // overview ids; omit for any\n defaultSpan: 2, minSpan: 1, maxSpan: 3,\n },\n] as const;\n```\n\nEmitted keys, in this order: `kind`, `type`, `export`, `label`, `description`,\n`icon`, `version`, `migrate`, `optionsEditor`, `options`, `data`, `allowedIn`, `defaultSpan`,\n`minSpan`, `maxSpan`; absent ones are omitted. Rules (each violation is a scan\nerror, same strictness as the other kinds):\n\n- `type` is a lowercase kebab id (`[a-z][a-z0-9-]{0,31}`), unique among a\n recipe's surfaces and across the package's recipes. `export`, `data.load`,\n `migrate`, and `optionsEditor` are `'<package specifier>#<ExportName>'`;\n relative specifiers are rejected. `label` is required. A UI host resolves an\n `optionsEditor` lazily for dependent or remote choices; the server registry\n never imports it, and every saved value still passes the ordinary option schema.\n- `options` is a non-empty list of plain-JSON fields limited to the overview\n option types: `text`, `markdown`, `identifier`, `model`, `integer`, `number`,\n `boolean`, closed `enum` (non-empty distinct `choices`). There is no free-form\n query, expression, URL or SQL type. Keys are camelCase (`[A-Za-z][A-Za-z0-9]{0,31}`)\n and unique; `default` must pass its field's own rule; `min`/`max` only on\n numbers, `maxLength` only on text. Only `key`, `type`, `label`, `help`,\n `required`, `default`, `min`, `max`, `maxLength`, `choices` are accepted.\n- `data` takes `load` and/or `models` (qualified class names, distinct). The\n scanner cannot see other packages, so a model is checked for format only.\n- `allowedIn` is a non-empty list of distinct overview ids\n (`[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}`); spans are integers 1 to 4 with\n `minSpan <= defaultSpan <= maxSpan`.\n\nThe scanner's copies of these rules live in `packages/scanner/src/recipe-widgets.ts`\nand a scanner test compares them to the smrt-svelte source. See\n[overview-surfaces.md](../../smrt-svelte/agents/overview-surfaces.md#recipe-widgets-recipe-widgetsts)\nfor how a host registers them.\n\n## Browser-demo mode: Live, Mock, Sample, Server (#3709)\n\nA catalog host (smrt-planner) labels each feature by how far it runs in a\nbrowser-only demo. The label is **derived at build time**, never authored: the\nmanifest build combines what the bundle-gate measured for the recipe's package\nwith the recipe's own `runtime`, `providers` and `demoSeed`, and emits it as\n`demo` on the recipe entry and `browser` on the package. A recipe that declares\nnone of those keeps its shape except for `demo`, which every recipe of a\nmeasured package gets.\n\n```jsonc\n// manifest.json and smrt-knowledge.json, top level\n\"browser\": {\n \"status\": \"browser-safe\" | \"server-only\",\n \"issues\": [\"#3624\"], // tracking issues; omitted when browser-safe\n \"reason\": \"node:crypto\", // the package's own blocker; omitted when only inherited\n \"via\": [\"@happyvertical/smrt-tenancy\"] // inherited from these; omitted when none\n}\n// on each entry of \"recipes\"\n\"demo\": {\n \"mode\": \"live\" | \"mock\" | \"sample\" | \"server\",\n \"reasons\": [\"Needs a server because ...\"], // plain sentences, in rule order\n \"mocked\": [\"smtp\"] // providers a demo fakes; omitted when none\n}\n```\n\n| Mode | Meaning |\n| --- | --- |\n| `live` | Real browser data layer (PGlite); nothing faked. |\n| `mock` | Runs in the browser, but a provider (sending mail, OAuth) is faked. |\n| `sample` | Runs in the browser on `demoSeed` fixtures only. |\n| `server` | Needs a server; a browser-only host cannot offer it. |\n\n**Package capability.** The browser gate (`packages/bundle-gate`) fails any\nmodel package that is not in its expected-failures list and does not build for\na browser, and any listed package that now builds. So a package is `server-only`\nexactly when it is listed. The ratchet attributes a defect to the package that\nowns the fix, so a dependent passes while still dragging the failure into a\nbrowser bundle; the derivation therefore also marks a package `server-only`\nwhen a workspace package it depends on (transitively, through `dependencies`)\nis, and records them in `via`. `smrt-core` is the platform baseline and does not\npropagate. The table lives in `src/manifest/browser-capability.generated.ts`\n(generated, never edited) and a bundle-gate spec fails when it is stale; see\nthat package's AGENTS.md for the regenerate command. A package the gate does\nnot measure (an app's own, a UI package) emits no `browser`, and its recipes get\na `demo` only when they declare `runtime`, `providers` or `demoSeed`.\n\n**Rules** (`src/recipe-demo.ts`, pure and browser-safe; the worst mode wins,\n`live` < `mock` < `sample` < `server`):\n\n1. `runtime: 'server'` is `server`. `runtime: 'both'` or omitted makes no claim:\n the package capability refines it, so `both` on a `server-only` package is\n `server`.\n2. A `server-only` package is `server`.\n3. A **server provider** is one that lists `secrets` (credentials a browser\n cannot hold). One with a `browserOptions` entry (an in-browser model such as\n `webllm`) is satisfied in a browser and changes nothing. Otherwise it is\n **mockable** when its `options` include `mock`. A *required* server provider\n that is neither is `sample` when the recipe has a `demoSeed` (the fixtures\n stand in for what the provider would feed), else `server`. An optional one\n that is neither leaves the mode alone and adds a reason.\n4. Any mockable server provider makes the recipe `mock` and lands in `mocked`.\n5. Otherwise `live`.\n\n**Contradictions fail the build** (like `assertRecipeOptions`), naming the\nrecipe: `runtime: 'browser'` on a `server-only` package, or beside a required\nserver provider with no in-browser and no `mock` option. Fix by giving the\nprovider a `browserOptions` entry or a `mock` option, making it optional, or\ndeclaring `both`/`server`. An explicit `both` or\n`server` on a `server-only` package is not a contradiction.\n\n**Dependencies between recipes.** `demo` is the recipe's own answer. A host that\nwants the answer including what the recipe needs calls\n`effectiveRecipeDemo(id, recipes)` (exported from both core entries): the worst\nof its own mode and every `requires` (transitively), counting the best known\nalternative of each `requiresAny` list; unknown ids are ignored.\n\n**Importing it in a browser page.** Use the subpath\n`@happyvertical/smrt-core/recipe-demo` (`deriveRecipeDemo`,\n`effectiveRecipeDemo`, `RECIPE_DEMO_MODES` and the demo types). Its module graph\nis `src/recipe-demo.ts` alone, with type-only imports, so it carries no `node:`\nimport. The root entry and `/browser` both also export these names, but both\nreach `manifest/manifest-loader.ts` through the registry, and that module calls\n`createRequire` from `node:module` as it loads, which a browser page cannot\nsatisfy. `src/__tests__/recipe-demo-entry.test.ts` walks the module graph and\nfails if the subpath gains a `node:` or package import.\n\nThe classification runs in `ManifestGenerator.applyGenerationPasses` (the one\npass sequence the Vite plugin, `ManifestBuilder` and `generateManifest()` share)\nthrough `applyRecipeDemo` in `src/scanner/recipe-demo-pass.ts`, after the\nmerged-manifest recipe assertions. It needs the package name, so it cannot run\nin the scanner, which resolves recipes before it knows the package.\n\n## How the scanner collects it\n\nThe scanner class pass finds classes by decorator, which a recipe lacks, so\n`recipes.ts` is a second matcher in the style of the agent-surface one: a class\nwhose `extends` resolves to the `SmrtRecipe` import binding from\n`@happyvertical/smrt-core` (named, aliased `SmrtRecipe as R`, or namespace\n`Core.SmrtRecipe`; any core subpath such as `/browser`). A same-named local\nclass or an import from another library is not a recipe. A recipe must extend\n`SmrtRecipe` **directly**. Within one file, extending another recipe or declaring\none as a class expression or inside a block is a scan error rather than a silent\ndrop. Across files it is not detected: a module that never names `SmrtRecipe`\nis not read, so a recipe extending a recipe or intermediate base imported from\nanother module is unsupported and would be missing from the artifacts.\n\nClass references resolve to qualified names (`@scope/pkg:Class`) through the\nimport binding: a relative specifier picks the scanned class by file, then by\nclass name (barrel re-exports keep the name); an aliased import follows the\n*imported* name; `Ns.Class` namespace members and same-file classes work; a\nbare package specifier is rejected (models must belong to this package). When\nthe imported file declares no such class, the barrel fallback binds a same-named\nscanned class, so keep model class names unique within a package. This\nwas chosen over qualified-name strings because it type-checks and survives\nrenames; strings were not needed.\n\nRecipe discovery is independent of the model `include` glob (like intents; it is\nnot gated by the `agentSurface` option), and\n`ScanResults.recipes` is populated by `resolve()`. Every error is a scan error,\nso every producer that reads `scanAndResolve()` (`smrtPlugin`, `ManifestBuilder`)\nfails the build. (The legacy `ManifestGenerator.generateManifest()` path takes\npre-scanned results and never sees recipes.) The adapter qualifies names and emits a top-level `recipes` array in\n`manifest.json`; `buildDomainKnowledgeManifest` projects the same array into\n`smrt-knowledge.json`. A package with no recipe emits no key.\n\n## Build-time validation\n\nIds are dotted lowercase and unique; `models` is non-empty, resolvable, and\nduplicate-free; every `nav` model is in `models`; `requires` are well-formed,\nnot self-referential or duplicated, and acyclic within the package (ids in other\npackages cannot be checked at scan time); `options` name only listed models and\nand only the keys below. Field names are checked in core\n(`ManifestGenerator.assertRecipeOptions`, after inherited fields merge) against\nthe merged manifest, so STI-merged, tenant, and framework-base fields such as\n`parentId` are accepted along with the universal `id`, `slug`, `context`,\n`created_at`, `updated_at`; anything else fails the build.\n\n## options: curation hints, never a second schema\n\nKeyed by model class name. `fields.<name>` accepts the smrt-fields policy\nvocabulary: `default`, `label`, `help`, `order`, `visibility`\n(`basic | advanced | hidden`), `locked`; applying a recipe produces\nfield-policy rows. `exposure.<api|mcp|cli>` accepts only `false` or\nnon-empty `{ exclude: [...] }` naming a CRUD verb (`list`, `get`, `create`,\n`update`, `delete`) or a public instance method the model declares or inherits: `true` and `include` are\nrejected because they can widen what the model already declares. Custom fields are out of scope.\n\n## User-facing help (#3591)\n\nHelp is documentation for the people using the app, not developers. It lives\nbeside the recipe as Markdown and is pointed to by `static help`:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n // ...\n static help = './sales.recipe.md'; // relative to the recipe file, .md only\n}\n```\n\nThe file holds an **Overview** and **Tasks** (short how-tos). A step names a\nfield as `{field:<fieldName>}` or `{field:<Model>.<fieldName>}`; the Markdown\nblock (heading, paragraph, or list item) containing a reference is **tied to\nthat field**. Keep prose in the separate file rather than the recipe class.\nField text itself comes from `@field({ description })`, the user-facing seed\nsmrt-fields uses for form help; every field a recipe shows should carry one.\n\n**Emission.** The scanner reads the file (a missing, empty, non-`.md`, absolute\nor `..`-climbing path is a scan error) and emits\n`help: { markdown, fieldRefs }` on the recipe entry in `manifest.json` and\n`smrt-knowledge.json`, so a static host needs no further request.\n`fieldRefs` (distinct references as written, sorted) is **derived**, never\nauthored. Knowledge fields also carry `description` (sensitive fields stay\nexcluded). `ManifestGenerator.assertRecipeHelp` fails the build on a reference\nto a field the recipe's models do not declare, and on a `fieldRefs` list that\ndisagrees with the Markdown. The scanner has its own copy of the reference\ngrammar (`deriveHelpFieldRefs`; it cannot import core), so that comparison also\nkeeps the two from drifting. A reference to a **sensitive** or universal (`id`,\n`slug`, ...) field also fails the build: neither reaches the knowledge artifact,\nso a host building its field list from it would silently drop the step naming\none; describe such a field in prose without a reference.\n\nKnown limitation: the help file is a generator input, but editing only it does\nnot trigger a dev-server rescan (the watch matches source globs) and generation\nsnapshots do not digest it, so rebuild (or touch a `.ts` source) after editing.\n\n**Rendering.** `src/recipe-help.ts` is pure and browser-safe (exported from\nboth core entries). `renderHelp(help, models, options?)` takes the recipe's\nhelp plus the app's effective field policies (`HelpModel[]`; label, help,\nvisibility, disabled in smrt-fields vocabulary, with `@field` descriptions) and\nreturns `{ blocks, glossary, connect? }`:\n\n- `{field:x}` becomes the effective label;\n- a block tied to a hidden, disabled, undeclared, or **advanced** field (unless\n `showAdvanced`) is dropped; a list item drops alone; a heading whose section\n had content that was all dropped is pruned (a heading that never had content\n stays);\n- the glossary lists each shown field with its effective help, else its\n description. A shown field with neither is **skipped**, not listed blank;\n- `connect` is the collapsed \"Connect other tools\" section: the recipe's\n generated REST routes, MCP tools and CLI commands (`options.surfaces`, from\n the knowledge artifact), minus the recipe's `exposure` narrowing\n (`options.exposure`). Hosts render it last.\n\nThe result is an AST of text nodes (`text`, `code`, `strong`, `em`), never HTML,\nso authored markup stays inert text and nothing needs sanitizing. Only\n`##`-`####` headings, paragraphs, flat lists and bold/italic/code are\nunderstood. `helpToMarkdown(rendered)` flattens it for hosts with their own\nrenderer; render that with raw HTML disabled. `validateHelp(help, models)`\nreturns the build-failure problems for hand-built entries.\n"
1220
1227
  },
1221
1228
  {
1222
1229
  "path": "agents/cookbook.md",
@@ -1,7 +1,7 @@
1
1
  import { dirname, join, resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { createRequire } from "node:module";
4
- import { pathToFileURL } from "node:url";
5
5
  //#region src/utils/import-project-vite.ts
6
6
  /** Picks the ESM entry of a package `exports["."]` target (string or conditions). */
7
7
  function esmEntry(target) {
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
- import { existsSync } from "node:fs";
3
2
  import { pathToFileURL } from "node:url";
3
+ import { existsSync } from "node:fs";
4
4
  //#region src/utils/import-workspace-module.ts
5
5
  function findWorkspaceRoot(startDir) {
6
6
  let current = startDir;
@@ -1 +1 @@
1
- {"version":3,"file":"api-client-entries.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/api-client-entries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,KAAK,EACV,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,qBAAqB,CAAC;AAS7B,MAAM,MAAM,mBAAmB,GAC3B,MAAM,GACN,KAAK,GACL,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,QAAQ,CAAC;AAEb,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,GAAG,EAAE,qBAAqB,CAAC;IAC3B,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,iBAAiB,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,WAAW,EAAE,mBAAmB,EAAE,CAAC;IACnC,sEAAsE;IACtE,aAAa,EAAE,KAAK,CAAC;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,GAAG,YAAY,CAAC;QAC7B,cAAc,EAAE,MAAM,EAAE,CAAC;QACzB,UAAU,EAAE,KAAK,CAAC;YAChB,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,MAAM,CAAC;YACb,QAAQ,CAAC,EAAE,OAAO,CAAC;YACnB,OAAO,CAAC,EAAE,OAAO,CAAC;SACnB,CAAC,CAAC;KACJ,CAAC,CAAC;CACJ;AAED,wBAAgB,uBAAuB,CACrC,iBAAiB,EAAE,MAAM,EACzB,WAAW,EAAE,mBAAmB,EAAE,EAClC,iBAAiB,GAAE,MAAM,EAAO,GAC/B,MAAM,GAAG,SAAS,CAepB;AAED,wBAAgB,qCAAqC,CACnD,MAAM,EAAE,cAAc,CAAC,eAAe,CAAC,CAAC,MAAM,CAAC,EAC/C,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAChC,MAAM,CAyCR;AA8SD;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,mBAAmB,GAC5B,cAAc,EAAE,CAwElB"}
1
+ {"version":3,"file":"api-client-entries.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/api-client-entries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,KAAK,EACV,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,qBAAqB,CAAC;AAS7B,MAAM,MAAM,mBAAmB,GAC3B,MAAM,GACN,KAAK,GACL,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,QAAQ,CAAC;AAEb,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,GAAG,EAAE,qBAAqB,CAAC;IAC3B,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,iBAAiB,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,WAAW,EAAE,mBAAmB,EAAE,CAAC;IACnC,sEAAsE;IACtE,aAAa,EAAE,KAAK,CAAC;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,GAAG,YAAY,CAAC;QAC7B,cAAc,EAAE,MAAM,EAAE,CAAC;QACzB,UAAU,EAAE,KAAK,CAAC;YAChB,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,MAAM,CAAC;YACb,QAAQ,CAAC,EAAE,OAAO,CAAC;YACnB,OAAO,CAAC,EAAE,OAAO,CAAC;SACnB,CAAC,CAAC;KACJ,CAAC,CAAC;CACJ;AAED,wBAAgB,uBAAuB,CACrC,iBAAiB,EAAE,MAAM,EACzB,WAAW,EAAE,mBAAmB,EAAE,EAClC,iBAAiB,GAAE,MAAM,EAAO,GAC/B,MAAM,GAAG,SAAS,CAepB;AAED,wBAAgB,qCAAqC,CACnD,MAAM,EAAE,cAAc,CAAC,eAAe,CAAC,CAAC,MAAM,CAAC,EAC/C,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAChC,MAAM,CAyCR;AAqTD;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,mBAAmB,GAC5B,cAAc,EAAE,CAwElB"}
@@ -73,6 +73,7 @@ function resolveGeneratedClientCustomMethods(obj, manifest) {
73
73
  const apiConfig = obj.decoratorConfig?.api;
74
74
  if (apiConfig === false) return [];
75
75
  const isCollection = isCollectionManifestClass(manifest, obj);
76
+ if (isCollection && resolveCollectionItemObject(manifest, obj)?.decoratorConfig?.abstract === true) return [];
76
77
  const isModelClassName = createManifestClassNamePredicate(manifest);
77
78
  return Object.entries(obj.methods ?? {}).flatMap(([name, method]) => {
78
79
  if (!resolveApiMethodExposure({
@@ -1 +1 @@
1
- {"version":3,"file":"api-client-entries.js","names":[],"sources":["../../src/vite-plugin/api-client-entries.ts"],"sourcesContent":["/**\n * Manifest -> generated API-client entry selection.\n *\n * Runtime client values, Vite ambient declarations, and physical prebuild\n * declarations must agree on which manifest object owns a collection's\n * canonical endpoint. Collection classes describe access methods, not row\n * payloads, so a populated model always wins the shared collection key.\n */\n\nimport {\n CRUD_OPERATIONS,\n createManifestClassNamePredicate,\n resolveApiMethodExposure,\n} from '../generators/custom-action.js';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types.js';\nimport { resolveApiActionRouteConfig } from './sveltekit-generator.js';\nimport {\n findManifestObjectByName,\n isCollectionManifestClass,\n orderedManifestObjectEntries,\n resolveCollectionItemObject,\n} from './web-collections.js';\n\nexport type ApiClientCrudMethod =\n | 'list'\n | 'get'\n | 'create'\n | 'update'\n | 'delete'\n | 'search';\n\nexport interface ApiClientEntry {\n /** Original key in manifest.objects. */\n objectName: string;\n /** Object whose routes and custom methods this client entry exposes. */\n obj: SmartObjectDefinition;\n /** Canonical collection key or deterministic class-derived secondary key. */\n clientKey: string;\n /** Row-payload interface used by CRUD methods for this entry. */\n dataInterfaceName: string;\n /** Standard client operations whose server routes are actually emitted. */\n crudMethods: ApiClientCrudMethod[];\n /** Custom methods whose routes are actually emitted by the server. */\n customMethods: Array<{\n name: string;\n scope: 'item' | 'collection';\n pathParamNames: string[];\n parameters: Array<{\n name: string;\n type: string;\n optional?: boolean;\n default?: unknown;\n }>;\n }>;\n}\n\nexport function renderApiClientCrudType(\n dataInterfaceName: string,\n crudMethods: ApiClientCrudMethod[],\n overriddenMethods: string[] = [],\n): string | undefined {\n const overridden = new Set(overriddenMethods);\n const methods = crudMethods.filter((method) => !overridden.has(method));\n if (methods.length === 0) return undefined;\n if (\n methods.length === 6 &&\n methods.every(\n (method, index) => method === [...CRUD_OPERATIONS, 'search'][index],\n )\n ) {\n return `CrudOperations<${dataInterfaceName}>`;\n }\n return `Pick<CrudOperations<${dataInterfaceName}>, ${methods\n .map((method) => JSON.stringify(method))\n .join(' | ')}>`;\n}\n\nexport function renderApiClientCustomMethodParameters(\n method: ApiClientEntry['customMethods'][number],\n mapType: (type: string) => string,\n): string {\n const pathParamNames = new Set(method.pathParamNames);\n const params = method.parameters;\n const hasSingleOptionsParameter =\n params.length === 1 && params[0]?.name === 'options';\n const requiredPathProperties = method.pathParamNames\n .map((name) => `${name}: string`)\n .join('; ');\n let optionsType: string;\n\n if (hasSingleOptionsParameter) {\n const directOptionsType = mapType(params[0].type);\n optionsType = requiredPathProperties\n ? `${directOptionsType} & { ${requiredPathProperties} }`\n : directOptionsType;\n } else {\n const parameterNames = new Set(params.map((param) => param.name));\n const properties = params.map(\n (param) =>\n `${param.name}${pathParamNames.has(param.name) || !param.optional ? '' : '?'}: ${mapType(param.type)}`,\n );\n for (const pathParamName of method.pathParamNames) {\n if (!parameterNames.has(pathParamName)) {\n properties.push(`${pathParamName}: string`);\n }\n }\n optionsType =\n properties.length > 0\n ? `{ ${properties.join('; ')} }`\n : 'Record<string, never>';\n }\n\n const hasRequiredOptions = hasSingleOptionsParameter\n ? !params[0]?.optional\n : params.some(\n (param) => !pathParamNames.has(param.name) && !param.optional,\n );\n const optionsParameter = `${method.pathParamNames.length > 0 || hasRequiredOptions ? 'options' : 'options?'}: ${optionsType}`;\n return method.scope === 'collection'\n ? optionsParameter\n : `id: string, ${optionsParameter}`;\n}\n\ninterface ManifestCandidate {\n objectName: string;\n obj: SmartObjectDefinition;\n}\n\nfunction compareText(left: string, right: string): number {\n if (left < right) return -1;\n if (left > right) return 1;\n return 0;\n}\n\nfunction candidateIdentity(candidate: ManifestCandidate): string {\n return [\n candidate.obj.qualifiedName ?? '',\n candidate.obj.className,\n candidate.objectName,\n ].join('\\0');\n}\n\nfunction compareCandidateIdentity(\n left: ManifestCandidate,\n right: ManifestCandidate,\n): number {\n return compareText(candidateIdentity(left), candidateIdentity(right));\n}\n\nfunction lowerFirst(value: string): string {\n return value ? value[0].toLowerCase() + value.slice(1) : value;\n}\n\nfunction isAncestorOf(\n manifest: SmartObjectManifest,\n ancestor: SmartObjectDefinition,\n descendant: SmartObjectDefinition,\n): boolean {\n const seen = new Set<string>();\n let child = descendant;\n let parentName = child.extendsQualified || child.extends;\n\n while (parentName && !seen.has(parentName)) {\n seen.add(parentName);\n const parent = findManifestObjectByName(manifest, parentName, child);\n if (!parent) return false;\n if (parent === ancestor) return true;\n child = parent;\n parentName = parent.extendsQualified || parent.extends;\n }\n\n return false;\n}\n\nconst CRUD_OPERATION_SET = new Set<string>(CRUD_OPERATIONS);\n\nfunction resolveGeneratedClientCustomMethods(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): ApiClientEntry['customMethods'] {\n const apiConfig = obj.decoratorConfig?.api;\n if (apiConfig === false) return [];\n\n const isCollection = isCollectionManifestClass(manifest, obj);\n const isModelClassName = createManifestClassNamePredicate(manifest);\n\n return Object.entries(obj.methods ?? {}).flatMap(([name, method]) => {\n // The client's whole contract is \"methods whose routes the server actually\n // emits\", so it reads the SAME resolver the emitters do rather than its own\n // include/exclude copy. Before #2686 that copy was equivalent; once the\n // wire-ability gate and `@method()` joined the decision, a local copy would\n // hand callers a typed `client.assets.createNewVersion(...)` pointing at a\n // route that is no longer written.\n if (\n !resolveApiMethodExposure({\n actionName: name,\n method,\n apiConfig,\n isCollectionClass: isCollection,\n ...(isModelClassName ? { isModelClassName } : {}),\n }).exposed\n ) {\n return [];\n }\n\n const routeConfig = resolveApiActionRouteConfig(\n name,\n method,\n apiConfig,\n {},\n isCollection ? 'collection' : method.isStatic ? 'collection' : 'item',\n );\n\n return [\n {\n name,\n scope: routeConfig.scope,\n pathParamNames: routeConfig.pathParamNames,\n parameters: method.parameters ?? [],\n },\n ];\n });\n}\n\nfunction resolveCrudObject(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): SmartObjectDefinition | undefined {\n if (!isCollectionManifestClass(manifest, obj)) return obj;\n\n const itemObject = resolveCollectionItemObject(manifest, obj);\n if (itemObject) return itemObject;\n\n // A non-conventional collection can still share an endpoint with a model.\n // Iterating the deterministic qualified-identity order (#2754) gives this\n // fallback the same total tie-break as every other manifest consumer.\n return orderedManifestObjectEntries(manifest)\n .filter(\n ([, candidate]) =>\n candidate.collection === obj.collection &&\n !isCollectionManifestClass(manifest, candidate),\n )\n .map(([, candidate]) => candidate)[0];\n}\n\nfunction resolveGeneratedClientCrudMethods(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): ApiClientCrudMethod[] {\n const crudObject = resolveCrudObject(obj, manifest);\n const apiConfig = crudObject?.decoratorConfig?.api;\n if (!crudObject || apiConfig === false) return [];\n\n if (apiConfig === true || apiConfig === undefined) {\n return [...CRUD_OPERATIONS, 'search'];\n }\n if (typeof apiConfig !== 'object' || apiConfig === null) {\n return [...CRUD_OPERATIONS, 'search'];\n }\n\n const included = Array.isArray(apiConfig.include)\n ? apiConfig.include\n : undefined;\n const excluded = Array.isArray(apiConfig.exclude) ? apiConfig.exclude : [];\n const standardMethods = (\n included\n ? CRUD_OPERATIONS.filter((action) => included.includes(action))\n : [...CRUD_OPERATIONS]\n ).filter((action) => !excluded.includes(action));\n\n // Preserve the historical search convenience only for an unbounded API\n // config. Explicit include lists are fail-closed; a real custom search\n // method is carried separately by resolveGeneratedClientCustomMethods().\n const includeSearch = !included && !excluded.includes('search');\n return includeSearch ? [...standardMethods, 'search'] : standardMethods;\n}\n\n/**\n * Resolve the CRUD surface that the generated route files actually expose for\n * one shared collection endpoint.\n *\n * Route generation processes manifest objects in the deterministic\n * qualified-identity order of {@link orderedManifestObjectEntries} (#2754):\n * collection and item handlers live in separate files, and the last model\n * that emits each file replaces the earlier file. Mirror that behavior here\n * by iterating the same order, so every client alias for a shared endpoint\n * has the same, real action set regardless of manifest scan order.\n */\nfunction resolveGeneratedEndpointCrudMethods(\n collection: string,\n manifest: SmartObjectManifest,\n): ApiClientCrudMethod[] {\n let collectionMethods: ApiClientCrudMethod[] = [];\n let itemMethods: ApiClientCrudMethod[] = [];\n\n for (const [, obj] of orderedManifestObjectEntries(manifest)) {\n if (\n obj.collection !== collection ||\n isCollectionManifestClass(manifest, obj)\n ) {\n continue;\n }\n\n const methods = resolveGeneratedClientCrudMethods(obj, manifest);\n const emittedCollectionMethods = methods.filter((method) =>\n ['list', 'create', 'search'].includes(method),\n );\n const emittedItemMethods = methods.filter((method) =>\n ['get', 'update', 'delete'].includes(method),\n );\n\n // generateRoutesForObject only writes a route file when that file has at\n // least one standard handler. A custom-only object leaves an earlier CRUD\n // file intact rather than replacing it with an empty file.\n if (\n emittedCollectionMethods.some(\n (method) => method === 'list' || method === 'create',\n )\n ) {\n collectionMethods = emittedCollectionMethods;\n }\n if (emittedItemMethods.length > 0) {\n itemMethods = emittedItemMethods;\n }\n }\n\n const emitted = new Set([...collectionMethods, ...itemMethods]);\n const orderedMethods: ApiClientCrudMethod[] = [...CRUD_OPERATIONS, 'search'];\n return orderedMethods.filter((method) => emitted.has(method));\n}\n\nfunction exposesApiClientSurface(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): boolean {\n if (obj.decoratorConfig?.api === false) return false;\n if (resolveGeneratedClientCrudMethods(obj, manifest).length > 0) return true;\n\n // A disabled item has no CRUD routes. Keep its companion collection only\n // when the collection itself contributes a custom route that the SvelteKit\n // generator will actually emit.\n return resolveGeneratedClientCustomMethods(obj, manifest).length > 0;\n}\n\nfunction resolveDataInterfaceName(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): string {\n if (!isCollectionManifestClass(manifest, obj)) {\n return `${obj.className}Data`;\n }\n\n const itemObject = resolveCollectionItemObject(manifest, obj);\n return `${itemObject?.className || obj.className}Data`;\n}\n\nfunction sharedCollectionModelDepth(\n manifest: SmartObjectManifest,\n obj: SmartObjectDefinition,\n): number {\n const seen = new Set<string>();\n let depth = 0;\n let child = obj;\n let parentName = child.extendsQualified || child.extends;\n\n while (parentName && !seen.has(parentName)) {\n seen.add(parentName);\n const parent = findManifestObjectByName(manifest, parentName, child);\n if (!parent || parent.collection !== obj.collection) break;\n depth += 1;\n child = parent;\n parentName = parent.extendsQualified || parent.extends;\n }\n\n return depth;\n}\n\n/**\n * Select the owner of one canonical collection endpoint with a transitive rank.\n *\n * Models beat collection classes because CRUD payloads are rows. Among models,\n * shallower same-collection ancestry wins, then a root with more descendants\n * wins (the STI base over an unrelated endpoint collision), then qualified\n * identity resolves any remaining ambiguity. Numeric/lexical tuple ranks stay\n * transitive, unlike pairwise ancestry overrides inside Array.sort().\n */\nfunction selectCanonicalOwner(\n manifest: SmartObjectManifest,\n group: ManifestCandidate[],\n): ManifestCandidate | undefined {\n const models = group.filter(\n (candidate) => !isCollectionManifestClass(manifest, candidate.obj),\n );\n\n if (models.length > 0) {\n return [...models]\n .map((candidate) => ({\n candidate,\n depth: sharedCollectionModelDepth(manifest, candidate.obj),\n descendantCount: models.filter(\n (other) =>\n other !== candidate &&\n isAncestorOf(manifest, candidate.obj, other.obj),\n ).length,\n }))\n .sort(\n (left, right) =>\n left.depth - right.depth ||\n right.descendantCount - left.descendantCount ||\n compareCandidateIdentity(left.candidate, right.candidate),\n )[0]?.candidate;\n }\n\n return [...group].sort((left, right) => {\n const leftHasItem = resolveCollectionItemObject(manifest, left.obj);\n const rightHasItem = resolveCollectionItemObject(manifest, right.obj);\n if (Boolean(leftHasItem) !== Boolean(rightHasItem)) {\n return leftHasItem ? -1 : 1;\n }\n return compareCandidateIdentity(left, right);\n })[0];\n}\n\n/**\n * Select every generated client entry with deterministic endpoint keys and row\n * payload types. Exactly one entry owns each canonical collection key; other\n * API objects remain available under stable class-derived secondary keys.\n */\nexport function selectApiClientEntries(\n manifest: SmartObjectManifest,\n): ApiClientEntry[] {\n const candidates = Object.entries(manifest.objects)\n .filter(([, obj]) => exposesApiClientSurface(obj, manifest))\n .map(([objectName, obj]): ManifestCandidate => ({ objectName, obj }));\n const candidatesByCollection = new Map<string, ManifestCandidate[]>();\n\n for (const candidate of candidates) {\n const group = candidatesByCollection.get(candidate.obj.collection) ?? [];\n group.push(candidate);\n candidatesByCollection.set(candidate.obj.collection, group);\n }\n\n const canonicalOwners = new Map<string, ManifestCandidate>();\n for (const [collection, group] of candidatesByCollection) {\n const canonicalOwner = selectCanonicalOwner(manifest, group);\n if (canonicalOwner) canonicalOwners.set(collection, canonicalOwner);\n }\n\n // Canonical owners are emitted first within each collection. Sorting all\n // remaining candidates by identity makes numeric suffixes stable too.\n const orderedCandidates = [...candidates].sort((left, right) => {\n const collectionOrder = compareText(\n left.obj.collection,\n right.obj.collection,\n );\n if (collectionOrder !== 0) return collectionOrder;\n\n const leftIsCanonical = canonicalOwners.get(left.obj.collection) === left;\n const rightIsCanonical =\n canonicalOwners.get(right.obj.collection) === right;\n if (leftIsCanonical !== rightIsCanonical) {\n return leftIsCanonical ? -1 : 1;\n }\n\n return compareCandidateIdentity(left, right);\n });\n\n // A secondary key must never shadow any canonical endpoint, including a\n // later collection whose canonical owner has not been emitted yet.\n const reservedCanonicalKeys = new Set(canonicalOwners.keys());\n const usedKeys = new Set<string>();\n\n return orderedCandidates.map(({ objectName, obj }) => {\n const isCanonicalOwner =\n canonicalOwners.get(obj.collection)?.objectName === objectName;\n let clientKey = isCanonicalOwner\n ? obj.collection\n : lowerFirst(obj.className);\n const baseClientKey = clientKey;\n let suffix = 2;\n\n while (\n usedKeys.has(clientKey) ||\n (!isCanonicalOwner && reservedCanonicalKeys.has(clientKey))\n ) {\n clientKey = `${baseClientKey}${suffix}`;\n suffix += 1;\n }\n\n usedKeys.add(clientKey);\n return {\n objectName,\n obj,\n clientKey,\n dataInterfaceName: resolveDataInterfaceName(obj, manifest),\n crudMethods: resolveGeneratedEndpointCrudMethods(\n obj.collection,\n manifest,\n ),\n customMethods: resolveGeneratedClientCustomMethods(obj, manifest),\n };\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"api-client-entries.js","names":[],"sources":["../../src/vite-plugin/api-client-entries.ts"],"sourcesContent":["/**\n * Manifest -> generated API-client entry selection.\n *\n * Runtime client values, Vite ambient declarations, and physical prebuild\n * declarations must agree on which manifest object owns a collection's\n * canonical endpoint. Collection classes describe access methods, not row\n * payloads, so a populated model always wins the shared collection key.\n */\n\nimport {\n CRUD_OPERATIONS,\n createManifestClassNamePredicate,\n resolveApiMethodExposure,\n} from '../generators/custom-action.js';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types.js';\nimport { resolveApiActionRouteConfig } from './sveltekit-generator.js';\nimport {\n findManifestObjectByName,\n isCollectionManifestClass,\n orderedManifestObjectEntries,\n resolveCollectionItemObject,\n} from './web-collections.js';\n\nexport type ApiClientCrudMethod =\n | 'list'\n | 'get'\n | 'create'\n | 'update'\n | 'delete'\n | 'search';\n\nexport interface ApiClientEntry {\n /** Original key in manifest.objects. */\n objectName: string;\n /** Object whose routes and custom methods this client entry exposes. */\n obj: SmartObjectDefinition;\n /** Canonical collection key or deterministic class-derived secondary key. */\n clientKey: string;\n /** Row-payload interface used by CRUD methods for this entry. */\n dataInterfaceName: string;\n /** Standard client operations whose server routes are actually emitted. */\n crudMethods: ApiClientCrudMethod[];\n /** Custom methods whose routes are actually emitted by the server. */\n customMethods: Array<{\n name: string;\n scope: 'item' | 'collection';\n pathParamNames: string[];\n parameters: Array<{\n name: string;\n type: string;\n optional?: boolean;\n default?: unknown;\n }>;\n }>;\n}\n\nexport function renderApiClientCrudType(\n dataInterfaceName: string,\n crudMethods: ApiClientCrudMethod[],\n overriddenMethods: string[] = [],\n): string | undefined {\n const overridden = new Set(overriddenMethods);\n const methods = crudMethods.filter((method) => !overridden.has(method));\n if (methods.length === 0) return undefined;\n if (\n methods.length === 6 &&\n methods.every(\n (method, index) => method === [...CRUD_OPERATIONS, 'search'][index],\n )\n ) {\n return `CrudOperations<${dataInterfaceName}>`;\n }\n return `Pick<CrudOperations<${dataInterfaceName}>, ${methods\n .map((method) => JSON.stringify(method))\n .join(' | ')}>`;\n}\n\nexport function renderApiClientCustomMethodParameters(\n method: ApiClientEntry['customMethods'][number],\n mapType: (type: string) => string,\n): string {\n const pathParamNames = new Set(method.pathParamNames);\n const params = method.parameters;\n const hasSingleOptionsParameter =\n params.length === 1 && params[0]?.name === 'options';\n const requiredPathProperties = method.pathParamNames\n .map((name) => `${name}: string`)\n .join('; ');\n let optionsType: string;\n\n if (hasSingleOptionsParameter) {\n const directOptionsType = mapType(params[0].type);\n optionsType = requiredPathProperties\n ? `${directOptionsType} & { ${requiredPathProperties} }`\n : directOptionsType;\n } else {\n const parameterNames = new Set(params.map((param) => param.name));\n const properties = params.map(\n (param) =>\n `${param.name}${pathParamNames.has(param.name) || !param.optional ? '' : '?'}: ${mapType(param.type)}`,\n );\n for (const pathParamName of method.pathParamNames) {\n if (!parameterNames.has(pathParamName)) {\n properties.push(`${pathParamName}: string`);\n }\n }\n optionsType =\n properties.length > 0\n ? `{ ${properties.join('; ')} }`\n : 'Record<string, never>';\n }\n\n const hasRequiredOptions = hasSingleOptionsParameter\n ? !params[0]?.optional\n : params.some(\n (param) => !pathParamNames.has(param.name) && !param.optional,\n );\n const optionsParameter = `${method.pathParamNames.length > 0 || hasRequiredOptions ? 'options' : 'options?'}: ${optionsType}`;\n return method.scope === 'collection'\n ? optionsParameter\n : `id: string, ${optionsParameter}`;\n}\n\ninterface ManifestCandidate {\n objectName: string;\n obj: SmartObjectDefinition;\n}\n\nfunction compareText(left: string, right: string): number {\n if (left < right) return -1;\n if (left > right) return 1;\n return 0;\n}\n\nfunction candidateIdentity(candidate: ManifestCandidate): string {\n return [\n candidate.obj.qualifiedName ?? '',\n candidate.obj.className,\n candidate.objectName,\n ].join('\\0');\n}\n\nfunction compareCandidateIdentity(\n left: ManifestCandidate,\n right: ManifestCandidate,\n): number {\n return compareText(candidateIdentity(left), candidateIdentity(right));\n}\n\nfunction lowerFirst(value: string): string {\n return value ? value[0].toLowerCase() + value.slice(1) : value;\n}\n\nfunction isAncestorOf(\n manifest: SmartObjectManifest,\n ancestor: SmartObjectDefinition,\n descendant: SmartObjectDefinition,\n): boolean {\n const seen = new Set<string>();\n let child = descendant;\n let parentName = child.extendsQualified || child.extends;\n\n while (parentName && !seen.has(parentName)) {\n seen.add(parentName);\n const parent = findManifestObjectByName(manifest, parentName, child);\n if (!parent) return false;\n if (parent === ancestor) return true;\n child = parent;\n parentName = parent.extendsQualified || parent.extends;\n }\n\n return false;\n}\n\nconst CRUD_OPERATION_SET = new Set<string>(CRUD_OPERATIONS);\n\nfunction resolveGeneratedClientCustomMethods(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): ApiClientEntry['customMethods'] {\n const apiConfig = obj.decoratorConfig?.api;\n if (apiConfig === false) return [];\n\n const isCollection = isCollectionManifestClass(manifest, obj);\n if (\n isCollection &&\n resolveCollectionItemObject(manifest, obj)?.decoratorConfig?.abstract ===\n true\n ) {\n return [];\n }\n const isModelClassName = createManifestClassNamePredicate(manifest);\n\n return Object.entries(obj.methods ?? {}).flatMap(([name, method]) => {\n // The client's whole contract is \"methods whose routes the server actually\n // emits\", so it reads the SAME resolver the emitters do rather than its own\n // include/exclude copy. Before #2686 that copy was equivalent; once the\n // wire-ability gate and `@method()` joined the decision, a local copy would\n // hand callers a typed `client.assets.createNewVersion(...)` pointing at a\n // route that is no longer written.\n if (\n !resolveApiMethodExposure({\n actionName: name,\n method,\n apiConfig,\n isCollectionClass: isCollection,\n ...(isModelClassName ? { isModelClassName } : {}),\n }).exposed\n ) {\n return [];\n }\n\n const routeConfig = resolveApiActionRouteConfig(\n name,\n method,\n apiConfig,\n {},\n isCollection ? 'collection' : method.isStatic ? 'collection' : 'item',\n );\n\n return [\n {\n name,\n scope: routeConfig.scope,\n pathParamNames: routeConfig.pathParamNames,\n parameters: method.parameters ?? [],\n },\n ];\n });\n}\n\nfunction resolveCrudObject(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): SmartObjectDefinition | undefined {\n if (!isCollectionManifestClass(manifest, obj)) return obj;\n\n const itemObject = resolveCollectionItemObject(manifest, obj);\n if (itemObject) return itemObject;\n\n // A non-conventional collection can still share an endpoint with a model.\n // Iterating the deterministic qualified-identity order (#2754) gives this\n // fallback the same total tie-break as every other manifest consumer.\n return orderedManifestObjectEntries(manifest)\n .filter(\n ([, candidate]) =>\n candidate.collection === obj.collection &&\n !isCollectionManifestClass(manifest, candidate),\n )\n .map(([, candidate]) => candidate)[0];\n}\n\nfunction resolveGeneratedClientCrudMethods(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): ApiClientCrudMethod[] {\n const crudObject = resolveCrudObject(obj, manifest);\n const apiConfig = crudObject?.decoratorConfig?.api;\n if (!crudObject || apiConfig === false) return [];\n\n if (apiConfig === true || apiConfig === undefined) {\n return [...CRUD_OPERATIONS, 'search'];\n }\n if (typeof apiConfig !== 'object' || apiConfig === null) {\n return [...CRUD_OPERATIONS, 'search'];\n }\n\n const included = Array.isArray(apiConfig.include)\n ? apiConfig.include\n : undefined;\n const excluded = Array.isArray(apiConfig.exclude) ? apiConfig.exclude : [];\n const standardMethods = (\n included\n ? CRUD_OPERATIONS.filter((action) => included.includes(action))\n : [...CRUD_OPERATIONS]\n ).filter((action) => !excluded.includes(action));\n\n // Preserve the historical search convenience only for an unbounded API\n // config. Explicit include lists are fail-closed; a real custom search\n // method is carried separately by resolveGeneratedClientCustomMethods().\n const includeSearch = !included && !excluded.includes('search');\n return includeSearch ? [...standardMethods, 'search'] : standardMethods;\n}\n\n/**\n * Resolve the CRUD surface that the generated route files actually expose for\n * one shared collection endpoint.\n *\n * Route generation processes manifest objects in the deterministic\n * qualified-identity order of {@link orderedManifestObjectEntries} (#2754):\n * collection and item handlers live in separate files, and the last model\n * that emits each file replaces the earlier file. Mirror that behavior here\n * by iterating the same order, so every client alias for a shared endpoint\n * has the same, real action set regardless of manifest scan order.\n */\nfunction resolveGeneratedEndpointCrudMethods(\n collection: string,\n manifest: SmartObjectManifest,\n): ApiClientCrudMethod[] {\n let collectionMethods: ApiClientCrudMethod[] = [];\n let itemMethods: ApiClientCrudMethod[] = [];\n\n for (const [, obj] of orderedManifestObjectEntries(manifest)) {\n if (\n obj.collection !== collection ||\n isCollectionManifestClass(manifest, obj)\n ) {\n continue;\n }\n\n const methods = resolveGeneratedClientCrudMethods(obj, manifest);\n const emittedCollectionMethods = methods.filter((method) =>\n ['list', 'create', 'search'].includes(method),\n );\n const emittedItemMethods = methods.filter((method) =>\n ['get', 'update', 'delete'].includes(method),\n );\n\n // generateRoutesForObject only writes a route file when that file has at\n // least one standard handler. A custom-only object leaves an earlier CRUD\n // file intact rather than replacing it with an empty file.\n if (\n emittedCollectionMethods.some(\n (method) => method === 'list' || method === 'create',\n )\n ) {\n collectionMethods = emittedCollectionMethods;\n }\n if (emittedItemMethods.length > 0) {\n itemMethods = emittedItemMethods;\n }\n }\n\n const emitted = new Set([...collectionMethods, ...itemMethods]);\n const orderedMethods: ApiClientCrudMethod[] = [...CRUD_OPERATIONS, 'search'];\n return orderedMethods.filter((method) => emitted.has(method));\n}\n\nfunction exposesApiClientSurface(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): boolean {\n if (obj.decoratorConfig?.api === false) return false;\n if (resolveGeneratedClientCrudMethods(obj, manifest).length > 0) return true;\n\n // A disabled item has no CRUD routes. Keep its companion collection only\n // when the collection itself contributes a custom route that the SvelteKit\n // generator will actually emit.\n return resolveGeneratedClientCustomMethods(obj, manifest).length > 0;\n}\n\nfunction resolveDataInterfaceName(\n obj: SmartObjectDefinition,\n manifest: SmartObjectManifest,\n): string {\n if (!isCollectionManifestClass(manifest, obj)) {\n return `${obj.className}Data`;\n }\n\n const itemObject = resolveCollectionItemObject(manifest, obj);\n return `${itemObject?.className || obj.className}Data`;\n}\n\nfunction sharedCollectionModelDepth(\n manifest: SmartObjectManifest,\n obj: SmartObjectDefinition,\n): number {\n const seen = new Set<string>();\n let depth = 0;\n let child = obj;\n let parentName = child.extendsQualified || child.extends;\n\n while (parentName && !seen.has(parentName)) {\n seen.add(parentName);\n const parent = findManifestObjectByName(manifest, parentName, child);\n if (!parent || parent.collection !== obj.collection) break;\n depth += 1;\n child = parent;\n parentName = parent.extendsQualified || parent.extends;\n }\n\n return depth;\n}\n\n/**\n * Select the owner of one canonical collection endpoint with a transitive rank.\n *\n * Models beat collection classes because CRUD payloads are rows. Among models,\n * shallower same-collection ancestry wins, then a root with more descendants\n * wins (the STI base over an unrelated endpoint collision), then qualified\n * identity resolves any remaining ambiguity. Numeric/lexical tuple ranks stay\n * transitive, unlike pairwise ancestry overrides inside Array.sort().\n */\nfunction selectCanonicalOwner(\n manifest: SmartObjectManifest,\n group: ManifestCandidate[],\n): ManifestCandidate | undefined {\n const models = group.filter(\n (candidate) => !isCollectionManifestClass(manifest, candidate.obj),\n );\n\n if (models.length > 0) {\n return [...models]\n .map((candidate) => ({\n candidate,\n depth: sharedCollectionModelDepth(manifest, candidate.obj),\n descendantCount: models.filter(\n (other) =>\n other !== candidate &&\n isAncestorOf(manifest, candidate.obj, other.obj),\n ).length,\n }))\n .sort(\n (left, right) =>\n left.depth - right.depth ||\n right.descendantCount - left.descendantCount ||\n compareCandidateIdentity(left.candidate, right.candidate),\n )[0]?.candidate;\n }\n\n return [...group].sort((left, right) => {\n const leftHasItem = resolveCollectionItemObject(manifest, left.obj);\n const rightHasItem = resolveCollectionItemObject(manifest, right.obj);\n if (Boolean(leftHasItem) !== Boolean(rightHasItem)) {\n return leftHasItem ? -1 : 1;\n }\n return compareCandidateIdentity(left, right);\n })[0];\n}\n\n/**\n * Select every generated client entry with deterministic endpoint keys and row\n * payload types. Exactly one entry owns each canonical collection key; other\n * API objects remain available under stable class-derived secondary keys.\n */\nexport function selectApiClientEntries(\n manifest: SmartObjectManifest,\n): ApiClientEntry[] {\n const candidates = Object.entries(manifest.objects)\n .filter(([, obj]) => exposesApiClientSurface(obj, manifest))\n .map(([objectName, obj]): ManifestCandidate => ({ objectName, obj }));\n const candidatesByCollection = new Map<string, ManifestCandidate[]>();\n\n for (const candidate of candidates) {\n const group = candidatesByCollection.get(candidate.obj.collection) ?? [];\n group.push(candidate);\n candidatesByCollection.set(candidate.obj.collection, group);\n }\n\n const canonicalOwners = new Map<string, ManifestCandidate>();\n for (const [collection, group] of candidatesByCollection) {\n const canonicalOwner = selectCanonicalOwner(manifest, group);\n if (canonicalOwner) canonicalOwners.set(collection, canonicalOwner);\n }\n\n // Canonical owners are emitted first within each collection. Sorting all\n // remaining candidates by identity makes numeric suffixes stable too.\n const orderedCandidates = [...candidates].sort((left, right) => {\n const collectionOrder = compareText(\n left.obj.collection,\n right.obj.collection,\n );\n if (collectionOrder !== 0) return collectionOrder;\n\n const leftIsCanonical = canonicalOwners.get(left.obj.collection) === left;\n const rightIsCanonical =\n canonicalOwners.get(right.obj.collection) === right;\n if (leftIsCanonical !== rightIsCanonical) {\n return leftIsCanonical ? -1 : 1;\n }\n\n return compareCandidateIdentity(left, right);\n });\n\n // A secondary key must never shadow any canonical endpoint, including a\n // later collection whose canonical owner has not been emitted yet.\n const reservedCanonicalKeys = new Set(canonicalOwners.keys());\n const usedKeys = new Set<string>();\n\n return orderedCandidates.map(({ objectName, obj }) => {\n const isCanonicalOwner =\n canonicalOwners.get(obj.collection)?.objectName === objectName;\n let clientKey = isCanonicalOwner\n ? obj.collection\n : lowerFirst(obj.className);\n const baseClientKey = clientKey;\n let suffix = 2;\n\n while (\n usedKeys.has(clientKey) ||\n (!isCanonicalOwner && reservedCanonicalKeys.has(clientKey))\n ) {\n clientKey = `${baseClientKey}${suffix}`;\n suffix += 1;\n }\n\n usedKeys.add(clientKey);\n return {\n objectName,\n obj,\n clientKey,\n dataInterfaceName: resolveDataInterfaceName(obj, manifest),\n crudMethods: resolveGeneratedEndpointCrudMethods(\n obj.collection,\n manifest,\n ),\n customMethods: resolveGeneratedClientCustomMethods(obj, manifest),\n };\n });\n}\n"],"mappings":"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"}
@@ -1,9 +1,9 @@
1
1
  import { AUTO_GENERATED_ROUTE_HEADER } from "./route-header.js";
2
2
  import { generateCollectionAccessImports } from "./sveltekit-config-import.js";
3
3
  import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
4
5
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
6
  import { createRequire } from "node:module";
6
- import { pathToFileURL } from "node:url";
7
7
  //#region src/vite-plugin/dev-plane-route.ts
8
8
  /**
9
9
  * Generated `_dev/[...tool]/+server.ts` route: the in-app runtime dev-plane