@happyvertical/smrt-core 0.53.4 → 0.53.5

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 (55) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +11 -0
  3. package/agents/build-knowledge.md +7 -0
  4. package/agents/generators.md +24 -1
  5. package/dist/generated-collection-access.d.ts +43 -0
  6. package/dist/generated-collection-access.d.ts.map +1 -0
  7. package/dist/generated-collection-access.js +71 -0
  8. package/dist/generated-collection-access.js.map +1 -0
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -2
  12. package/dist/manifest/static-manifest.js +1 -1
  13. package/dist/manifest/static-manifest.js.map +1 -1
  14. package/dist/manifest/store.js +1 -1
  15. package/dist/manifest.json +1 -1
  16. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  17. package/dist/scanner/manifest-generator.js +3 -1
  18. package/dist/scanner/manifest-generator.js.map +1 -1
  19. package/dist/smrt-knowledge.json +9 -9
  20. package/dist/utils/qualified-names.d.ts +15 -0
  21. package/dist/utils/qualified-names.d.ts.map +1 -1
  22. package/dist/utils/qualified-names.js +31 -1
  23. package/dist/utils/qualified-names.js.map +1 -1
  24. package/dist/vite-plugin/changes-route.d.ts +4 -3
  25. package/dist/vite-plugin/changes-route.d.ts.map +1 -1
  26. package/dist/vite-plugin/changes-route.js +8 -7
  27. package/dist/vite-plugin/changes-route.js.map +1 -1
  28. package/dist/vite-plugin/dev-plane-route.d.ts.map +1 -1
  29. package/dist/vite-plugin/dev-plane-route.js +4 -4
  30. package/dist/vite-plugin/dev-plane-route.js.map +1 -1
  31. package/dist/vite-plugin/events-route.d.ts.map +1 -1
  32. package/dist/vite-plugin/events-route.js +4 -4
  33. package/dist/vite-plugin/events-route.js.map +1 -1
  34. package/dist/vite-plugin/index.d.ts.map +1 -1
  35. package/dist/vite-plugin/index.js +13 -1
  36. package/dist/vite-plugin/index.js.map +1 -1
  37. package/dist/vite-plugin/resources-route.d.ts.map +1 -1
  38. package/dist/vite-plugin/resources-route.js +9 -4
  39. package/dist/vite-plugin/resources-route.js.map +1 -1
  40. package/dist/vite-plugin/sveltekit-config-import.d.ts +22 -0
  41. package/dist/vite-plugin/sveltekit-config-import.d.ts.map +1 -1
  42. package/dist/vite-plugin/sveltekit-config-import.js +44 -2
  43. package/dist/vite-plugin/sveltekit-config-import.js.map +1 -1
  44. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  45. package/dist/vite-plugin/sveltekit-generator.js +59 -9
  46. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  47. package/dist/vite-plugin/sveltekit-register-injection.d.ts +26 -0
  48. package/dist/vite-plugin/sveltekit-register-injection.d.ts.map +1 -0
  49. package/dist/vite-plugin/sveltekit-register-injection.js +37 -0
  50. package/dist/vite-plugin/sveltekit-register-injection.js.map +1 -0
  51. package/dist/vite-plugin/sync-apply-route.d.ts +1 -1
  52. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
  53. package/dist/vite-plugin/sync-apply-route.js +4 -4
  54. package/dist/vite-plugin/sync-apply-route.js.map +1 -1
  55. package/package.json +10 -10
@@ -3,13 +3,13 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.53.4",
6
+ "packageVersion": "0.53.5",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "c73aaa8357575331407e64ef13fca7d7b2ee739ae5d93667589efc88aca768ae",
11
- "packageJson": "8ccb3a612cacc9adb84fbbe17465ad7d9d38dbbd1623b7573a39fa1a6a5dc37d",
12
- "agents": "36f26d684abee7ee2f552f0e0ee929eaa6d63cbba1f88bfd8e0f60d1b4cca81d",
10
+ "manifest": "926e4aff069b8e02dc4f5a4cb9f0ccbef3de8646746f3826e9d0569923c7194a",
11
+ "packageJson": "cd916e16fadad4d089664ac3acd8dc9e68b56ad5a90f89138b1d174d7564f877",
12
+ "agents": "96b806acf27098acd999a6ce5490497d4de2cb625e691cefa508fa154a9b528f",
13
13
  "moduleDoc:agents/object-runtime.md": "39a68186cdb57bc9d9dfacb805b19a00133f189f6e287e6ebe2fb347418b878b",
14
14
  "moduleDoc:agents/revision-guard.md": "aa6b1ddb5b6b49fa27ebbe575ec7fcd6d5ceb35ee25acc702f877a226eb9a99a",
15
15
  "moduleDoc:agents/collection-reads.md": "e5bb79b59c17e79a85a71580a019e6b0b641e498ac4d850073fd5ad634b57cb7",
@@ -18,8 +18,8 @@
18
18
  "moduleDoc:agents/schema-paths.md": "d0916af6366f2a917da261f728a3628429c69d63291f6f8c1e005ebff5a05e4a",
19
19
  "moduleDoc:agents/change-feed.md": "2151843f5d059cff54d7f6a32a6d57c43b9551fc4673edce13209764a09a9ae1",
20
20
  "moduleDoc:agents/change-signals.md": "a2dbe7077483b5a19bd5ea9589b5a08fe847377c8e05d01d57f813c6d9cbc28b",
21
- "moduleDoc:agents/generators.md": "e10ee8cd8f41c5e784f5fd0325a0f597fc386a225c40536223a8a6f982bbddca",
22
- "moduleDoc:agents/build-knowledge.md": "9d0cce3d214a50c9bbe122232f7b0c3101560da30b106051058e454705556497",
21
+ "moduleDoc:agents/generators.md": "22d25a3b7fcc6b53bed879ccbf29e5640768ceea78fc84cd4c050cbb687447a1",
22
+ "moduleDoc:agents/build-knowledge.md": "8426577f7abe55cdc0c966606e528dcba60540f1e925b4e4cc14cfdd26980314",
23
23
  "moduleDoc:agents/memory.md": "a86bdf370403d08ecad3be86e341eb322dfe6e6b7a4e8806a6f8be9bf3421da5",
24
24
  "moduleDoc:agents/system-diagnostics.md": "b7ccae18ee312bf788d5470f95fa78d3535f1789247d5372e40ca86538290772",
25
25
  "moduleDoc:agents/registry-snapshot.md": "6234aa540427396d1dda1825a4641b1072e247187d5ddf413ae46c691344ee30",
@@ -1065,7 +1065,7 @@
1065
1065
  "polymorphicAssociations": 1,
1066
1066
  "uuidColumns": 3
1067
1067
  },
1068
- "agentDoc": "# @happyvertical/smrt-core\n\nFoundation ORM, registry, schema/code generation, AI integration, and DispatchBus.\nRead the module for the subsystem being edited; root AGENTS covers shared model\nand repository rules.\n\n## Modules\n\n| Source | Scope | Module doc |\n|---|---|---|\n| `src/object.ts`, `src/collection.ts`, `src/child-accessors.ts` | Lifecycle, hydration, operators, STI, child accessors, dispatch | [agents/object-runtime.md](agents/object-runtime.md) |\n| `src/revision-guard.ts` | Guarded writes and PostgreSQL revision precision | [agents/revision-guard.md](agents/revision-guard.md) |\n| `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n| `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |\n| `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |\n| `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |\n| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |\n| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |\n| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |\n| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |\n| `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |\n| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Per-class registry work on the hydration path (manifest reconciliation in\n `ensureManifestLoaded()`, the simple-name index behind `findClass()`) is\n memoized per registry generation (`src/registry/generation.ts`, #3047). Any\n new writer of registered classes, their field/method metadata, or loaded\n manifests must go through the tracked maps or call\n `bumpRegistryGeneration()`; `issue-3047-hydration-registry-work.test.ts`\n guards that hydration does not repeat this work per row.\n\n- Production DDL uses manifest generators; `getTestDatabase()` uses registry\n generators. Keep columns, indexes, FK actions, and runtime conflict targets in\n parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate\n needs its index or an explicit reason none is needed.\n- Tenant scoping covers every read and every unique/conflict key. Explicit\n conflict columns are not rewritten; their author must include tenant scope.\n A `tenantId` field makes rows tenant-OWNED without any tenancy declaration:\n the default key leads with `tenant_id`. A new object's natural-key save reads\n the row its upsert would hit and refuses a different owner\n (`TENANT_ISOLATION_VIOLATION`) or adopts a same-owner row's id, so\n `DO UPDATE` never rewrites a primary key or owner. Only an explicit natural\n key adopts: a slug `getSlug()` derived from `name`/`title`/`label` moves to\n a free `-2`, `-3`, … slug and INSERTs. See `agents/schema-paths.md`\n (\"Tenant OWNERSHIP\").\n- Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.\n Preserve revision compare-and-swap ordering through public `save()`,\n `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete\n `withTransaction()` callbacks share a process-local write queue.\n- Collection model hydration is serial in result order: initialization may query\n the same transaction-bound PostgreSQL client. Use projections for plain rows.\n- Native DuckDB UUIDs must be cast coherently on read before identity reuse;\n custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay\n a mutation to discover result types.\n- `withDatabase(db, callback)` restores only database bindings (including public\n `options.db`); `withTransaction(callback)` also restores identity/revision\n metadata after rollback. Do not use a bound instance concurrently.\n- `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,\n #3080) claims a key derived from all four inputs — never the token alone,\n which collapses distinct submissions — via `INSERT ... ON CONFLICT\n (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL\n `_smrt_run_once_claims` system table, and runs `work` in the SAME\n transaction; a completed claim replays its stored result instead of\n re-running, a failed `work` rolls the claim back with the transaction, and\n an unresolved claim raises typed `RunOnceClaimError`\n (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.\n The claim insert must never raise on conflict: PostgreSQL aborts the whole\n transaction on any raised error, which would take the recovery `SELECT`\n down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`\n caught for `isUniqueViolationError`. Completed claims are bounded by\n `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,\n `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on\n `completed_at`; `in_progress` rows are never matched) — the window is a\n replay deadline, not just disk hygiene: a token replayed after its claim\n is swept runs `work()` again. See `agents/run-once.md`.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses a transaction-scoped advisory lock, taken only when the\n PostgreSQL schema version is not yet stamped: inside a caller's transaction it\n is held until that transaction ends (#2868). Application tables still require migrations;\n runtime table verification checks existence only.\n- Manifest generation fails closed on scanner errors, including unresolved\n decorator spreads. Never emit partial, default-open registration.\n- Generated registration repairs bundled class identity using the exact imported\n constructor, explicit package, and isolated one-object manifest. Never infer\n ownership from paths, simple names, or table names; packages can share names.\n Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.\n- Decorator registration trusts a class's stack-derived package only when that\n package declares the class (stub, own manifest entry, or explicit\n `packageName`); it then matches the qualified key and never adopts another\n package's same-named registration. Otherwise a loaded (or the cwd project's)\n manifest entry describing the declaring file supplies the identity — apps\n scan workspace-package sources under the app's name (#3110). A class of\n another known package coexists under its qualified key\n (`decorator-different-packages-qualified-coexist`); bundled output needs\n a resolved table that differs first (#3106). Stack\n attribution skips smrt-core (including source-mapped installed\n `smrt-core/src` frames, #3109), decorator-helper, and module-runner frames.\n Instance and collection registry lookups pass the qualified name. The schema\n planner rejects a table claimed by two unrelated classes\n (`CONFIG_TABLE_NAME_COLLISION`): only one STI family shares a table (#3098);\n collections (including `SmrtReportCollection`) never claim one. Consumer\n gates: `registry/__tests__/issue-3110-*.test.ts`, `issue-3109-*.test.ts`,\n `packages/bundle-gate/src/__tests__/consumer-same-name.spec.ts`.\n- API custom-action eligibility has ONE resolver, `resolveApiMethodExposure()`\n in `generators/custom-action.ts`: both SvelteKit route emitters,\n `resolveApiActionSet`, and `knowledge.ts` read it, and a new consumer must too\n — a local mirror is how a method gets reported unavailable while its route\n file is still written. A public method routes by default only when every\n parameter is JSON-shaped; `@method({ expose })` overrides in both directions,\n `expose: true` bypasses the heuristic ALONE, and an explicit `api.include` or\n `api.routes` entry keeps its pre-#2686 route. Fail closed on scanner\n uncertainty: read `parameters[].typeUnresolved`, never the `'any'` it\n substitutes. Accepting `Date` as wire-able and hydrating it\n (`toCustomActionDate`) are one decision — changing either breaks the other.\n The runtime `APIGenerator` transport stays declaration-gated: `rest.ts`\n dispatch and `preflight-route.ts` prediction both read\n `declaresRuntimeRestRoute()`, which must accept every `ApiCustomRouteConfig`\n option so a sweep moving one onto its method cannot delete the endpoint. Its\n twin `declaresRuntimeRestRouteShape()` deliberately ignores `expose: false`:\n the dispatcher must still SEE a withheld declaration to answer 404, because\n `POST /<collection>/<segment>` resolves to `create` when nothing claims the\n segment. Split unions and type arguments with `splitTopLevel()` — a naive\n `split('|')` truncates `Record<string, Asset | null>` into fragments that\n match no rule and are then accepted, widening the gate. `extractTypeName`\n returns `null` for a generic with an unresolvable ARGUMENT or a union with an\n unresolvable BRANCH so those reach that fail-closed path instead of arriving\n as a bare `'Array'`. Runtime transports read\n `ObjectRegistry.resolveRuntimeMethod()`, which tries the item class's manifest\n entry, then the COLLECTION class's (where a collection-hosted action's\n parameters live), then the live `@method()` store — keyed by CONSTRUCTOR,\n never by simple name (`Account` exists in two packages), and recording\n `isStatic` because an unscanned runtime has no manifest to recover the\n receiver from. `isRestActionRoutable` ANDs \"declared\" with that receiver, so\n it never predicts `allow` for an action dispatch refuses. A declared action\n with no receiver is refused (501 collection-scoped, 404 item-scoped), never\n allowed to fall through into `create`. All eight consumers read the resolver,\n including `packages/smrt-workbench/src/discovery.ts`; it imports the\n `./generators/custom-action` LEAF subpath, not `./generators`, because that\n barrel value-re-exports `MCPGenerator` and so drags `@happyvertical/ai` and\n `@happyvertical/sql` into a build-time helper (2 modules vs 109). Keep\n `custom-action.ts` free of value imports outside `tools/tool-generator`.\n\n## Gotchas\n\n- Optional filesystem support stays lazy: use `createFilesystemAdapter()` in\n `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps\n import `@happyvertical/smrt-core/filesystem` at startup. Use\n `importOptionalDependency()` for similarly heavy optional dependencies.\n- Database retries are transient-only, four attempts total for `get`/`upsert`.\n Use `src/db-errors.ts` classifiers through the cause chain, never message\n matching (SDK driver text can live in `context.originalError`). Constraints,\n bad input, missing tables, and aborted PostgreSQL transactions fail immediately.\n Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes\n `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.\n- Property initializers precede option values; options win. Arrays/objects are\n shallow-cloned. Collection creation caches fields; table verification caches\n by DB URL and table. Preserve these scopes when changing initialization.\n- The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild\n core after editing those sources before testing consumer manifest generation.\n Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.\n\n## Validation\n\n`typecheck` checks production source first, then the test project in\n`tsconfig.test.json`. Test coverage and the shared relaxed config\ncontract are documented in [agents/test-types.md](agents/test-types.md).\n\nRun focused tests first, then applicable package checks:\n\n```bash\npnpm --filter @happyvertical/smrt-core test\npnpm --filter @happyvertical/smrt-core typecheck\npnpm --filter @happyvertical/smrt-core build\npnpm --filter @happyvertical/smrt-core test:postgres\npnpm check:agents-chain\npnpm smrt dev:knowledge-check\n```\n\nThe PostgreSQL lane is required for numeric types, UUID casts, conflict targets,\ntimestamps, and migrations. Tests generate their manifest before Vitest; restart\nwatch mode after adding decorated classes. Documentation-only changes need\ninstruction-chain and knowledge freshness checks, not the runtime test suite.\n",
1068
+ "agentDoc": "# @happyvertical/smrt-core\n\nFoundation ORM, registry, schema/code generation, AI integration, and DispatchBus.\nRead the module for the subsystem being edited; root AGENTS covers shared model\nand repository rules.\n\n## Modules\n\n| Source | Scope | Module doc |\n|---|---|---|\n| `src/object.ts`, `src/collection.ts`, `src/child-accessors.ts` | Lifecycle, hydration, operators, STI, child accessors, dispatch | [agents/object-runtime.md](agents/object-runtime.md) |\n| `src/revision-guard.ts` | Guarded writes and PostgreSQL revision precision | [agents/revision-guard.md](agents/revision-guard.md) |\n| `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n| `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |\n| `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |\n| `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |\n| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |\n| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |\n| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |\n| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |\n| `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |\n| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Per-class registry work on the hydration path (manifest reconciliation in\n `ensureManifestLoaded()`, the simple-name index behind `findClass()`) is\n memoized per registry generation (`src/registry/generation.ts`, #3047). Any\n new writer of registered classes, their field/method metadata, or loaded\n manifests must go through the tracked maps or call\n `bumpRegistryGeneration()`; `issue-3047-hydration-registry-work.test.ts`\n guards that hydration does not repeat this work per row.\n\n- Production DDL uses manifest generators; `getTestDatabase()` uses registry\n generators. Keep columns, indexes, FK actions, and runtime conflict targets in\n parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate\n needs its index or an explicit reason none is needed.\n- Tenant scoping covers every read and every unique/conflict key. Explicit\n conflict columns are not rewritten; their author must include tenant scope.\n A `tenantId` field makes rows tenant-OWNED without any tenancy declaration:\n the default key leads with `tenant_id`. A new object's natural-key save reads\n the row its upsert would hit and refuses a different owner\n (`TENANT_ISOLATION_VIOLATION`) or adopts a same-owner row's id, so\n `DO UPDATE` never rewrites a primary key or owner. Only an explicit natural\n key adopts: a slug `getSlug()` derived from `name`/`title`/`label` moves to\n a free `-2`, `-3`, … slug and INSERTs. See `agents/schema-paths.md`\n (\"Tenant OWNERSHIP\").\n- Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.\n Preserve revision compare-and-swap ordering through public `save()`,\n `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete\n `withTransaction()` callbacks share a process-local write queue.\n- Collection model hydration is serial in result order: initialization may query\n the same transaction-bound PostgreSQL client. Use projections for plain rows.\n- Native DuckDB UUIDs must be cast coherently on read before identity reuse;\n custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay\n a mutation to discover result types.\n- `withDatabase(db, callback)` restores only database bindings (including public\n `options.db`); `withTransaction(callback)` also restores identity/revision\n metadata after rollback. Do not use a bound instance concurrently.\n- `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,\n #3080) claims a key derived from all four inputs — never the token alone,\n which collapses distinct submissions — via `INSERT ... ON CONFLICT\n (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL\n `_smrt_run_once_claims` system table, and runs `work` in the SAME\n transaction; a completed claim replays its stored result instead of\n re-running, a failed `work` rolls the claim back with the transaction, and\n an unresolved claim raises typed `RunOnceClaimError`\n (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.\n The claim insert must never raise on conflict: PostgreSQL aborts the whole\n transaction on any raised error, which would take the recovery `SELECT`\n down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`\n caught for `isUniqueViolationError`. Completed claims are bounded by\n `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,\n `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on\n `completed_at`; `in_progress` rows are never matched) — the window is a\n replay deadline, not just disk hygiene: a token replayed after its claim\n is swept runs `work()` again. See `agents/run-once.md`.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses a transaction-scoped advisory lock, taken only when the\n PostgreSQL schema version is not yet stamped: inside a caller's transaction it\n is held until that transaction ends (#2868). Application tables still require migrations;\n runtime table verification checks existence only.\n- Manifest generation fails closed on scanner errors, including unresolved\n decorator spreads. Never emit partial, default-open registration.\n- Generated registration repairs bundled class identity using the exact imported\n constructor, explicit package, and isolated one-object manifest. Never infer\n ownership from paths, simple names, or table names; packages can share names.\n Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.\n- Decorator registration trusts a class's stack-derived package only when that\n package declares the class (stub, own manifest entry, or explicit\n `packageName`); it then matches the qualified key and never adopts another\n package's same-named registration. Otherwise a loaded (or the cwd project's)\n manifest entry describing the declaring file supplies the identity — apps\n scan workspace-package sources under the app's name (#3110). A class of\n another known package coexists under its qualified key\n (`decorator-different-packages-qualified-coexist`); bundled output needs\n a resolved table that differs first (#3106). Stack\n attribution skips smrt-core (including source-mapped installed\n `smrt-core/src` frames, #3109), decorator-helper, and module-runner frames.\n Qualified names are `@scope/pkg:Class`; the scope is a deliberate\n discriminator, so manifest generation (`generateManifest`,\n `applyGenerationPasses`, hence `pnpm build`) throws\n `CONFIG_UNSCOPED_PACKAGE_NAME` for an unscoped `package.json` name (#3408).\n Instance and collection registry lookups pass the qualified name. The schema\n planner rejects a table claimed by two unrelated classes\n (`CONFIG_TABLE_NAME_COLLISION`): only one STI family shares a table (#3098);\n collections (including `SmrtReportCollection`) never claim one. Consumer\n gates: `registry/__tests__/issue-3110-*.test.ts`, `issue-3109-*.test.ts`,\n `packages/bundle-gate/src/__tests__/consumer-same-name.spec.ts`.\n- API custom-action eligibility has ONE resolver, `resolveApiMethodExposure()`\n in `generators/custom-action.ts`: both SvelteKit route emitters,\n `resolveApiActionSet`, and `knowledge.ts` read it, and a new consumer must too\n — a local mirror is how a method gets reported unavailable while its route\n file is still written. A public method routes by default only when every\n parameter is JSON-shaped; `@method({ expose })` overrides in both directions,\n `expose: true` bypasses the heuristic ALONE, and an explicit `api.include` or\n `api.routes` entry keeps its pre-#2686 route. Fail closed on scanner\n uncertainty: read `parameters[].typeUnresolved`, never the `'any'` it\n substitutes. Accepting `Date` as wire-able and hydrating it\n (`toCustomActionDate`) are one decision — changing either breaks the other.\n The runtime `APIGenerator` transport stays declaration-gated: `rest.ts`\n dispatch and `preflight-route.ts` prediction both read\n `declaresRuntimeRestRoute()`, which must accept every `ApiCustomRouteConfig`\n option so a sweep moving one onto its method cannot delete the endpoint. Its\n twin `declaresRuntimeRestRouteShape()` deliberately ignores `expose: false`:\n the dispatcher must still SEE a withheld declaration to answer 404, because\n `POST /<collection>/<segment>` resolves to `create` when nothing claims the\n segment. Split unions and type arguments with `splitTopLevel()` — a naive\n `split('|')` truncates `Record<string, Asset | null>` into fragments that\n match no rule and are then accepted, widening the gate. `extractTypeName`\n returns `null` for a generic with an unresolvable ARGUMENT or a union with an\n unresolvable BRANCH so those reach that fail-closed path instead of arriving\n as a bare `'Array'`. Runtime transports read\n `ObjectRegistry.resolveRuntimeMethod()`, which tries the item class's manifest\n entry, then the COLLECTION class's (where a collection-hosted action's\n parameters live), then the live `@method()` store — keyed by CONSTRUCTOR,\n never by simple name (`Account` exists in two packages), and recording\n `isStatic` because an unscanned runtime has no manifest to recover the\n receiver from. `isRestActionRoutable` ANDs \"declared\" with that receiver, so\n it never predicts `allow` for an action dispatch refuses. A declared action\n with no receiver is refused (501 collection-scoped, 404 item-scoped), never\n allowed to fall through into `create`. All eight consumers read the resolver,\n including `packages/smrt-workbench/src/discovery.ts`; it imports the\n `./generators/custom-action` LEAF subpath, not `./generators`, because that\n barrel value-re-exports `MCPGenerator` and so drags `@happyvertical/ai` and\n `@happyvertical/sql` into a build-time helper (2 modules vs 109). Keep\n `custom-action.ts` free of value imports outside `tools/tool-generator`.\n\n## Gotchas\n\n- Optional filesystem support stays lazy: use `createFilesystemAdapter()` in\n `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps\n import `@happyvertical/smrt-core/filesystem` at startup. Use\n `importOptionalDependency()` for similarly heavy optional dependencies.\n- Database retries are transient-only, four attempts total for `get`/`upsert`.\n Use `src/db-errors.ts` classifiers through the cause chain, never message\n matching (SDK driver text can live in `context.originalError`). Constraints,\n bad input, missing tables, and aborted PostgreSQL transactions fail immediately.\n Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes\n `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.\n- Property initializers precede option values; options win. Arrays/objects are\n shallow-cloned. Collection creation caches fields; table verification caches\n by DB URL and table. Preserve these scopes when changing initialization.\n- The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild\n core after editing those sources before testing consumer manifest generation.\n Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.\n\n## Validation\n\n`typecheck` checks production source first, then the test project in\n`tsconfig.test.json`. Test coverage and the shared relaxed config\ncontract are documented in [agents/test-types.md](agents/test-types.md).\n\nRun focused tests first, then applicable package checks:\n\n```bash\npnpm --filter @happyvertical/smrt-core test\npnpm --filter @happyvertical/smrt-core typecheck\npnpm --filter @happyvertical/smrt-core build\npnpm --filter @happyvertical/smrt-core test:postgres\npnpm check:agents-chain\npnpm smrt dev:knowledge-check\n```\n\nThe PostgreSQL lane is required for numeric types, UUID casts, conflict targets,\ntimestamps, and migrations. Tests generate their manifest before Vitest; restart\nwatch mode after adding decorated classes. Documentation-only changes need\ninstruction-chain and knowledge freshness checks, not the runtime test suite.\n",
1069
1069
  "moduleDocs": [
1070
1070
  {
1071
1071
  "path": "agents/object-runtime.md",
@@ -1110,12 +1110,12 @@
1110
1110
  {
1111
1111
  "path": "agents/generators.md",
1112
1112
  "module": "generators",
1113
- "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 generated `$lib/server/smrt` so the plane shares the app's connection.\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"
1113
+ "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## 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"
1114
1114
  },
1115
1115
  {
1116
1116
  "path": "agents/build-knowledge.md",
1117
1117
  "module": "build-knowledge",
1118
- "content": "# Decorators, build integration, and knowledge\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes; see [schema-paths.md](schema-paths.md)), `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`, `ui` (`{ icon, label, description }` — nav/help hints round-tripped through the manifest as plain data; `description` is the object-level seed for form-level help, #2046).\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\n## @field() UI hints (#2046)\n\n`@field({ ui: { basic, group, order, locked } })` — a static, presentation-only\nseed for the field-policy rail (epic #2045). Carried in the manifest under the\nfield's `_meta.ui` (never a top-level `FieldDefinition` key), readable at\nruntime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with\n`description` into generated web-collection definitions and browser MCP tool\nschemas. No schema/persistence/security effect — `sensitive`/`readPermission`\nstay the security rail, and `sensitive`/`transient` fields never emit to the\nclient at all.\n\n## Worker registration artifact (#3117)\n\nWith `svelteKit.enabled`, a production `vite build` compiles the generated\n`smrt-register.ts` into `.smrt/runtime/register.js` after the server build\n(`src/vite-plugin/worker-registration.ts`, called once from `closeBundle`).\nadapter-node re-bundles the server from its own entries, so the web bundle is\nnever importable by a separate Node process; this artifact is how a deployed\ntask/schedule worker registers the app's local objects (and, through the\n`.smrt/register.js` it imports, consumed packages' objects) with the same\nqualified identities and isolated manifests as the web server. It is a nested\nSSR build with no consumer plugins: only aliases pointing into the project\nsources (e.g. `$lib`) apply, and every other bare import stays external so the\nworker shares the installed `@happyvertical/smrt-core` registry. SvelteKit\nvirtual modules (`$env/*`, `$app/*`) are not available to a worker; an object\nimporting one fails at worker startup. A failure fails the build.\n\n## Lightweight discovery\n\nFilesystem-only discovery, module-doc parsing, and graph freshness are owned by\n`@happyvertical/smrt-scanner/knowledge`. Core's knowledge exports remain compatible\nreexports. Runtime manifest projection and custom-action semantics stay in core.\n\n`src/knowledge-discovery.ts`, exported through `smrt-core/knowledge`, enumerates\ninstalled scope directories and reads canonical AGENTS/legacy CLAUDE docs without\nloading package code, artifacts, or scanning objects. CLI snapshots and MCP share\nthese primitives. Callers retain selection policy: snapshots resolve links and\nfall back to `packages/*` only when no installed SMRT package loads; MCP preserves\nnode_modules paths, deduplicates realpaths, excludes authored workspace links,\nand then enriches the selected packages. Workspace-root/glob discovery remains\nowned by each consumer.\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nThe CLI's schema-command gate reads that same merged file: a pure-consumer\nproject (0 local objects) qualifies on the consumed packages its manifest\nrecords, so a clobbered `objects` map is what made `smrt db:migrate` report\n`missing_local_manifest` (#2925).\n\n`.smrt/manifest.json` has two writers and neither owns all of it: `smrtPlugin()`\ncontributes the project's scanned objects and `smrtConsumer()` the consumed\npackages' entries. Both writes are merge-preserving, keyed on each entry's\nrecorded `packageName` — a plain overwrite in either direction silently drops\nthe other's objects, and the last pass of a multi-environment build is not\nreliably the aggregating one (#1760, #2925). `dist/manifest.json` stays\nlocal-only: a published package must not carry its dependencies' objects.\n\nThe schema-version-1 object projection is additive and high-signal: it retains\nnormalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,\nmethod signatures, and field defaults/constraints/readonly/transient flags.\nSensitive fields are removed before both `fields` and `relationships` are\nderived, including legacy flags stored under `_meta`; matching field and\nsnake-case column names are also removed from projected conflict columns, and a\nsensitive custom tenant field is omitted while retaining scope and mode.\nGenerated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;\nthe optional marker keeps schema version 1 additive while letting readers\nidentify older artifacts that require raw-manifest corroboration.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nTest generation uses that same effective configuration and publishes knowledge\nmatching the manifest it generated. Disabling knowledge removes a previous local\nknowledge artifact, so consumers cannot mistake it for current enabled output.\n`new ManifestBuilder(projectRoot)` keeps discovery, package identity, and output\nat that explicit root, including installed providers and external base classes;\nomitting the argument retains the current-working-directory\nbehavior. Both manifest writers retain declared package dependencies when merging\ntheir local and consumed-object projections.\n\nTest manifests carry optional `artifactPurpose: 'test'`; dependency discovery\nskips those artifacts, including `.smrt` fallbacks. Unmarked runtime manifests,\nempty published providers, and source-only development providers remain eligible.\n`ManifestBuilder` accepts explicit `outputMode: 'dev' | 'build'`; omitted mode\nretains legacy filename inference. Vitest selects dev mode explicitly, so tests\nnever replace a production `dist/manifest.json`. Relative output directories are\nresolved against the builder's project root.\n\nTracked `src/lib/server/smrt-register.ts` belongs to SvelteKit config/type\ngeneration. Generic library builds consume it but do not cache or restore it\nas an output; otherwise a concurrent config producer's bytes can be replayed\nover a newer registration after dependency eligibility changes.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n\n## Vite Plugin\n\n`src/vite-preset/` (`@happyvertical/smrt-core/vite`, `smrt()`) composes the Oxc\ndecorator block, `smrtConsumer`, then `smrtPlugin` with the template path\nconventions. Its package list comes only from the `smrt({ packages })` option, or\n`smrt.config` `consumer.packages` (smrt-config schema); it never sniffs\n`package.json`, and a missing or malformed list throws. Keep its output\nbyte-identical to the two-plugin form (`src/vite-preset/index.test.ts`). It\nalso dedupes `@sveltejs/kit` (`smrt:shared-runtime`): SvelteKit matches\n`redirect()`/`fail()` by class, and a linked/workspace SMRT SvelteKit entry\nwould otherwise import its own copy and turn redirects into 500s.\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\nFor independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept\nthe same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The\nschema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the\nmerged project/dependency manifest, portable source paths, and source-file\ndigests; each plugin selects its own view. Reuse mode fails closed on\nbyte/provenance/path/content drift, skips scans and manifest writes, and still\ngenerates routes, types, registration, and virtual modules. Omit it for normal\nlocal development and watch mode.\n\n## Clean consumer type checks (TypeScript 6)\n\nThe consumer config hook writes `.smrt/register.js` alongside generated types\nafter route preflight succeeds. Generated SvelteKit registration imports that\nmodule, so it must exist during `svelte-kit sync`, before Vite's `buildStart`.\nBoth hosted routes and the legacy `svelteKit: true` mode follow this order.\nThe clean-consumer regression fixture ships real provider declarations and\nchecks the generated imports with `svelte-check`.\nCore's `generate:test` task depends on its own `build`: both publish the local\n`.smrt` manifest/knowledge pair, so Turbo must serialize production before test\ngeneration instead of caching an interleaved pair.\n"
1118
+ "content": "# Decorators, build integration, and knowledge\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes; see [schema-paths.md](schema-paths.md)), `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`, `ui` (`{ icon, label, description }` — nav/help hints round-tripped through the manifest as plain data; `description` is the object-level seed for form-level help, #2046).\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\n## @field() UI hints (#2046)\n\n`@field({ ui: { basic, group, order, locked } })` — a static, presentation-only\nseed for the field-policy rail (epic #2045). Carried in the manifest under the\nfield's `_meta.ui` (never a top-level `FieldDefinition` key), readable at\nruntime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with\n`description` into generated web-collection definitions and browser MCP tool\nschemas. No schema/persistence/security effect — `sensitive`/`readPermission`\nstay the security rail, and `sensitive`/`transient` fields never emit to the\nclient at all.\n\n## Worker registration artifact (#3117)\n\nWith `svelteKit.enabled`, a production `vite build` compiles the generated\n`smrt-register.ts` into `.smrt/runtime/register.js` after the server build\n(`src/vite-plugin/worker-registration.ts`, called once from `closeBundle`).\nadapter-node re-bundles the server from its own entries, so the web bundle is\nnever importable by a separate Node process; this artifact is how a deployed\ntask/schedule worker registers the app's local objects (and, through the\n`.smrt/register.js` it imports, consumed packages' objects) with the same\nqualified identities and isolated manifests as the web server. It is a nested\nSSR build with no consumer plugins: only aliases pointing into the project\nsources (e.g. `$lib`) apply, and every other bare import stays external so the\nworker shares the installed `@happyvertical/smrt-core` registry. SvelteKit\nvirtual modules (`$env/*`, `$app/*`) are not available to a worker; an object\nimporting one fails at worker startup. A failure fails the build.\n\n## Lightweight discovery\n\nFilesystem-only discovery, module-doc parsing, and graph freshness are owned by\n`@happyvertical/smrt-scanner/knowledge`. Core's knowledge exports remain compatible\nreexports. Runtime manifest projection and custom-action semantics stay in core.\n\n`src/knowledge-discovery.ts`, exported through `smrt-core/knowledge`, enumerates\ninstalled scope directories and reads canonical AGENTS/legacy CLAUDE docs without\nloading package code, artifacts, or scanning objects. CLI snapshots and MCP share\nthese primitives. Callers retain selection policy: snapshots resolve links and\nfall back to `packages/*` only when no installed SMRT package loads; MCP preserves\nnode_modules paths, deduplicates realpaths, excludes authored workspace links,\nand then enriches the selected packages. Workspace-root/glob discovery remains\nowned by each consumer.\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nThe CLI's schema-command gate reads that same merged file: a pure-consumer\nproject (0 local objects) qualifies on the consumed packages its manifest\nrecords, so a clobbered `objects` map is what made `smrt db:migrate` report\n`missing_local_manifest` (#2925).\n\n`.smrt/manifest.json` has two writers and neither owns all of it: `smrtPlugin()`\ncontributes the project's scanned objects and `smrtConsumer()` the consumed\npackages' entries. Both writes are merge-preserving, keyed on each entry's\nrecorded `packageName` — a plain overwrite in either direction silently drops\nthe other's objects, and the last pass of a multi-environment build is not\nreliably the aggregating one (#1760, #2925). `dist/manifest.json` stays\nlocal-only: a published package must not carry its dependencies' objects.\n\nThe schema-version-1 object projection is additive and high-signal: it retains\nnormalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,\nmethod signatures, and field defaults/constraints/readonly/transient flags.\nSensitive fields are removed before both `fields` and `relationships` are\nderived, including legacy flags stored under `_meta`; matching field and\nsnake-case column names are also removed from projected conflict columns, and a\nsensitive custom tenant field is omitted while retaining scope and mode.\nGenerated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;\nthe optional marker keeps schema version 1 additive while letting readers\nidentify older artifacts that require raw-manifest corroboration.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nTest generation uses that same effective configuration and publishes knowledge\nmatching the manifest it generated. Disabling knowledge removes a previous local\nknowledge artifact, so consumers cannot mistake it for current enabled output.\n`new ManifestBuilder(projectRoot)` keeps discovery, package identity, and output\nat that explicit root, including installed providers and external base classes;\nomitting the argument retains the current-working-directory\nbehavior. Both manifest writers retain declared package dependencies when merging\ntheir local and consumed-object projections.\n\nTest manifests carry optional `artifactPurpose: 'test'`; dependency discovery\nskips those artifacts, including `.smrt` fallbacks. Unmarked runtime manifests,\nempty published providers, and source-only development providers remain eligible.\n`ManifestBuilder` accepts explicit `outputMode: 'dev' | 'build'`; omitted mode\nretains legacy filename inference. Vitest selects dev mode explicitly, so tests\nnever replace a production `dist/manifest.json`. Relative output directories are\nresolved against the builder's project root.\n\nWith `svelteKit.enabled`, `smrtPlugin()`'s transform prepends\n`import './smrt-register.js';` to the configured config module\n(`<configPath>/<configFileName>`, `src/vite-plugin/sveltekit-register-injection.ts`)\nonce the register module exists, on the same first line so line numbers hold.\nApps therefore never hand-write the guarded registration import (#3416); a\nconfig module that still has it imports the same module twice, harmlessly.\n\nTracked `src/lib/server/smrt-register.ts` belongs to SvelteKit config/type\ngeneration. Generic library builds consume it but do not cache or restore it\nas an output; otherwise a concurrent config producer's bytes can be replayed\nover a newer registration after dependency eligibility changes.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n\n## Vite Plugin\n\n`src/vite-preset/` (`@happyvertical/smrt-core/vite`, `smrt()`) composes the Oxc\ndecorator block, `smrtConsumer`, then `smrtPlugin` with the template path\nconventions. Its package list comes only from the `smrt({ packages })` option, or\n`smrt.config` `consumer.packages` (smrt-config schema); it never sniffs\n`package.json`, and a missing or malformed list throws. Keep its output\nbyte-identical to the two-plugin form (`src/vite-preset/index.test.ts`). It\nalso dedupes `@sveltejs/kit` (`smrt:shared-runtime`): SvelteKit matches\n`redirect()`/`fail()` by class, and a linked/workspace SMRT SvelteKit entry\nwould otherwise import its own copy and turn redirects into 500s.\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\nFor independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept\nthe same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The\nschema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the\nmerged project/dependency manifest, portable source paths, and source-file\ndigests; each plugin selects its own view. Reuse mode fails closed on\nbyte/provenance/path/content drift, skips scans and manifest writes, and still\ngenerates routes, types, registration, and virtual modules. Omit it for normal\nlocal development and watch mode.\n\n## Clean consumer type checks (TypeScript 6)\n\nThe consumer config hook writes `.smrt/register.js` alongside generated types\nafter route preflight succeeds. Generated SvelteKit registration imports that\nmodule, so it must exist during `svelte-kit sync`, before Vite's `buildStart`.\nBoth hosted routes and the legacy `svelteKit: true` mode follow this order.\nThe clean-consumer regression fixture ships real provider declarations and\nchecks the generated imports with `svelte-check`.\nCore's `generate:test` task depends on its own `build`: both publish the local\n`.smrt` manifest/knowledge pair, so Turbo must serialize production before test\ngeneration instead of caching an interleaved pair.\n"
1119
1119
  },
1120
1120
  {
1121
1121
  "path": "agents/memory.md",
@@ -58,6 +58,21 @@ export declare function parseQualifiedName(qualifiedName: string): ParsedQualifi
58
58
  * isQualifiedName('smrt-core:Product') // false (no @ scope)
59
59
  */
60
60
  export declare function isQualifiedName(name: string): name is QualifiedClassName;
61
+ /**
62
+ * Whether a package name can be the package half of a qualified class name,
63
+ * i.e. exactly what {@link isQualifiedName} requires: a scoped npm name
64
+ * (`@scope/pkg`). The scope is a deliberate discriminator (#713): a bare
65
+ * `word:Word` is never treated as qualified by lookups, STI discriminator
66
+ * classification (`smrt db:sti-upgrade`), aliases or cross-package refs.
67
+ */
68
+ export declare function isScopedPackageName(packageName: unknown): boolean;
69
+ /**
70
+ * Fail at scan/generation time when a package name cannot produce a qualified
71
+ * class name, instead of emitting `pkg:Class` keys that the registry's own
72
+ * validator rejects later (`CONFIG_INVALID_ISOLATED_MANIFEST`, #3408).
73
+ * `undefined` is allowed: no package name means no qualified names are generated.
74
+ */
75
+ export declare function assertScopedPackageName(packageName: string | undefined): void;
61
76
  /**
62
77
  * Extracts just the class name from a qualified name or simple name.
63
78
  * Useful when you need the class name regardless of whether
@@ -1 +1 @@
1
- {"version":3,"file":"qualified-names.d.ts","sourceRoot":"","sources":["../../src/utils/qualified-names.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,kBAAkB,CAcpB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,mBAAmB,CAgB7E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,kBAAkB,CAyBxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKjD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK5E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAEjE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,GAClB,OAAO,CAMT;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,MAAM,CACpB,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,SAAS,EAAE,MAAM,GAChB,OAAO,CAKT;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,MAAM,CAWxE;AAED,yEAAyE;AACzE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,EAAE,CAMpE"}
1
+ {"version":3,"file":"qualified-names.d.ts","sourceRoot":"","sources":["../../src/utils/qualified-names.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,kBAAkB,CAcpB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,mBAAmB,CAgB7E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,kBAAkB,CAyBxE;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,OAAO,GAAG,OAAO,CAMjE;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAW7E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKjD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK5E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAEjE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,GAClB,OAAO,CAMT;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,MAAM,CACpB,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,SAAS,EAAE,MAAM,GAChB,OAAO,CAKT;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,MAAM,CAWxE;AAED,yEAAyE;AACzE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,EAAE,CAMpE"}
@@ -1,5 +1,15 @@
1
+ import { ConfigurationError } from "../errors.js";
1
2
  //#region src/utils/qualified-names.ts
2
3
  /**
4
+ * Utilities for working with qualified class names in the SMRT framework.
5
+ *
6
+ * Qualified names follow the format: "@package/name:ClassName"
7
+ * Example: "@happyvertical/smrt-core:Product"
8
+ *
9
+ * This format uniquely identifies classes across packages, eliminating
10
+ * name collisions when multiple packages define classes with the same name.
11
+ */
12
+ /**
3
13
  * Creates a qualified class name from package name and class name.
4
14
  *
5
15
  * @param packageName - The npm package name (e.g., "@happyvertical/smrt-core")
@@ -61,6 +71,26 @@ function isQualifiedName(name) {
61
71
  return true;
62
72
  }
63
73
  /**
74
+ * Whether a package name can be the package half of a qualified class name,
75
+ * i.e. exactly what {@link isQualifiedName} requires: a scoped npm name
76
+ * (`@scope/pkg`). The scope is a deliberate discriminator (#713): a bare
77
+ * `word:Word` is never treated as qualified by lookups, STI discriminator
78
+ * classification (`smrt db:sti-upgrade`), aliases or cross-package refs.
79
+ */
80
+ function isScopedPackageName(packageName) {
81
+ return typeof packageName === "string" && packageName.startsWith("@") && packageName.includes("/");
82
+ }
83
+ /**
84
+ * Fail at scan/generation time when a package name cannot produce a qualified
85
+ * class name, instead of emitting `pkg:Class` keys that the registry's own
86
+ * validator rejects later (`CONFIG_INVALID_ISOLATED_MANIFEST`, #3408).
87
+ * `undefined` is allowed: no package name means no qualified names are generated.
88
+ */
89
+ function assertScopedPackageName(packageName) {
90
+ if (packageName === void 0 || isScopedPackageName(packageName)) return;
91
+ throw new ConfigurationError(`SMRT requires a scoped package name to qualify class names, but the package.json name is "${packageName}". Rename it to a scoped name such as "@my-app/${packageName.replace(/^@[^/]*\/?/u, "")}". Qualified class names have the form "@scope/package:ClassName"; unscoped names are never treated as qualified.`, "CONFIG_UNSCOPED_PACKAGE_NAME", { packageName });
92
+ }
93
+ /**
64
94
  * Extracts just the class name from a qualified name or simple name.
65
95
  * Useful when you need the class name regardless of whether
66
96
  * the input is qualified or not.
@@ -164,6 +194,6 @@ function readPreviousQualifiedNames(config) {
164
194
  return declared.filter((name) => typeof name === "string");
165
195
  }
166
196
  //#endregion
167
- export { createQualifiedName, getClassName, getPackageFromQualifiedName, isFromPackage, isQualifiedName, isQualifiedNameAliasFormat, isType, parseQualifiedName, qualifiedNamesEqual, readPreviousQualifiedNames };
197
+ export { assertScopedPackageName, createQualifiedName, getClassName, getPackageFromQualifiedName, isFromPackage, isQualifiedName, isQualifiedNameAliasFormat, isScopedPackageName, isType, parseQualifiedName, qualifiedNamesEqual, readPreviousQualifiedNames };
168
198
 
169
199
  //# sourceMappingURL=qualified-names.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"qualified-names.js","names":[],"sources":["../../src/utils/qualified-names.ts"],"sourcesContent":["/**\n * Utilities for working with qualified class names in the SMRT framework.\n *\n * Qualified names follow the format: \"@package/name:ClassName\"\n * Example: \"@happyvertical/smrt-core:Product\"\n *\n * This format uniquely identifies classes across packages, eliminating\n * name collisions when multiple packages define classes with the same name.\n */\n\nimport type { QualifiedClassName } from '../scanner/types.js';\n\n/**\n * Creates a qualified class name from package name and class name.\n *\n * @param packageName - The npm package name (e.g., \"@happyvertical/smrt-core\")\n * @param className - The class name (e.g., \"Product\")\n * @returns Qualified name in format \"@package/name:ClassName\"\n *\n * @example\n * createQualifiedName('@happyvertical/smrt-core', 'Product')\n * // Returns: \"@happyvertical/smrt-core:Product\"\n */\nexport function createQualifiedName(\n packageName: string,\n className: string,\n): QualifiedClassName {\n if (!packageName) {\n throw new Error(\n `Cannot create qualified name: packageName is required. ` +\n `Got className=\"${className}\" but no packageName.`,\n );\n }\n if (!className) {\n throw new Error(\n `Cannot create qualified name: className is required. ` +\n `Got packageName=\"${packageName}\" but no className.`,\n );\n }\n return `${packageName}:${className}` as QualifiedClassName;\n}\n\n/**\n * Result of parsing a qualified class name.\n */\nexport interface ParsedQualifiedName {\n /** The package name (e.g., \"@happyvertical/smrt-core\") */\n packageName: string;\n /** The class name (e.g., \"Product\") */\n className: string;\n}\n\n/**\n * Parses a qualified class name into its components.\n *\n * @param qualifiedName - The qualified name to parse\n * @returns Object with packageName and className\n * @throws Error if the name is not a valid qualified name\n *\n * @example\n * parseQualifiedName('@happyvertical/smrt-core:Product')\n * // Returns: { packageName: '@happyvertical/smrt-core', className: 'Product' }\n */\nexport function parseQualifiedName(qualifiedName: string): ParsedQualifiedName {\n if (!isQualifiedName(qualifiedName)) {\n throw new Error(\n `Invalid qualified name: \"${qualifiedName}\". ` +\n `Expected format: \"@package/name:ClassName\"`,\n );\n }\n\n // Use lastIndexOf to handle edge cases like \"@scope/pkg:Nested:Class\"\n // (though class names shouldn't have colons, this is defensive)\n const colonIndex = qualifiedName.lastIndexOf(':');\n\n return {\n packageName: qualifiedName.slice(0, colonIndex),\n className: qualifiedName.slice(colonIndex + 1),\n };\n}\n\n/**\n * Checks if a name is a valid qualified class name.\n *\n * A valid qualified name:\n * - Contains a colon (:) separating package and class\n * - Has a package name with a forward slash (scoped npm package)\n * - Has a non-empty class name after the colon\n *\n * @param name - The name to check\n * @returns true if the name is a valid qualified class name\n *\n * @example\n * isQualifiedName('@happyvertical/smrt-core:Product') // true\n * isQualifiedName('Product') // false\n * isQualifiedName('smrt-core:Product') // false (no @ scope)\n */\nexport function isQualifiedName(name: string): name is QualifiedClassName {\n if (!name || typeof name !== 'string') {\n return false;\n }\n\n const colonIndex = name.lastIndexOf(':');\n if (colonIndex === -1) {\n return false;\n }\n\n const packageName = name.slice(0, colonIndex);\n const className = name.slice(colonIndex + 1);\n\n // Package name must be a scoped npm package (contains @)\n // and have a path (contains /)\n if (!packageName.startsWith('@') || !packageName.includes('/')) {\n return false;\n }\n\n // Class name must be non-empty\n if (!className) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Extracts just the class name from a qualified name or simple name.\n * Useful when you need the class name regardless of whether\n * the input is qualified or not.\n *\n * @param name - Qualified name or simple class name\n * @returns The simple class name\n *\n * @example\n * getClassName('@happyvertical/smrt-core:Product') // 'Product'\n * getClassName('Product') // 'Product'\n */\nexport function getClassName(name: string): string {\n if (isQualifiedName(name)) {\n return parseQualifiedName(name).className;\n }\n return name;\n}\n\n/**\n * Extracts the package name from a qualified name.\n * Returns undefined if the name is not qualified.\n *\n * @param name - Qualified name or simple class name\n * @returns The package name, or undefined if not qualified\n *\n * @example\n * getPackageFromQualifiedName('@happyvertical/smrt-core:Product')\n * // Returns: '@happyvertical/smrt-core'\n *\n * getPackageFromQualifiedName('Product')\n * // Returns: undefined\n */\nexport function getPackageFromQualifiedName(name: string): string | undefined {\n if (isQualifiedName(name)) {\n return parseQualifiedName(name).packageName;\n }\n return undefined;\n}\n\n/**\n * Compares two qualified names for equality.\n * Case-sensitive comparison.\n *\n * @param a - First qualified name\n * @param b - Second qualified name\n * @returns true if the qualified names are equal\n */\nexport function qualifiedNamesEqual(a: string, b: string): boolean {\n return a === b;\n}\n\n/**\n * Checks if a qualified name belongs to a specific package.\n *\n * @param qualifiedName - The qualified name to check\n * @param packageName - The package name to match against\n * @returns true if the qualified name is from the specified package\n *\n * @example\n * isFromPackage('@happyvertical/smrt-core:Product', '@happyvertical/smrt-core')\n * // Returns: true\n */\nexport function isFromPackage(\n qualifiedName: string,\n packageName: string,\n): boolean {\n if (!isQualifiedName(qualifiedName)) {\n return false;\n }\n const parsed = parseQualifiedName(qualifiedName);\n return parsed.packageName === packageName;\n}\n\n/**\n * Checks if a qualified name (like `_meta_type`) matches a short class name.\n * This is useful for STI filtering where the database stores qualified names\n * but code often uses short names for comparisons.\n *\n * @param qualifiedName - The qualified name or simple name to check (e.g., from `_meta_type`)\n * @param shortName - The short class name to match against\n * @returns true if the class name portion matches\n *\n * @example\n * ```typescript\n * // Filtering STI records by type\n * const meetings = events.filter(e => isType(e._meta_type, 'Meeting'));\n *\n * // Works with both qualified and simple names\n * isType('@happyvertical/praeco:MeetingRecap', 'MeetingRecap') // true\n * isType('@happyvertical/praeco:MeetingRecap', 'Article') // false\n * isType('MeetingRecap', 'MeetingRecap') // true (simple name passthrough)\n * ```\n */\nexport function isType(\n qualifiedName: string | null | undefined,\n shortName: string,\n): boolean {\n if (!qualifiedName) {\n return false;\n }\n return getClassName(qualifiedName) === shortName;\n}\n\n/**\n * A valid `previousQualifiedNames` entry: exactly what {@link isQualifiedName}\n * accepts (`@scope/package:ClassName` — the predicate eager lookups,\n * `resolveType()` and the lazy manifest loader all use), with an identifier\n * class name and exactly one colon. An unscoped `package:ClassName` is\n * refused: no qualified lookup treats it as qualified, so it could never\n * resolve (#3338).\n */\nexport function isQualifiedNameAliasFormat(name: unknown): name is string {\n if (typeof name !== 'string' || !isQualifiedName(name)) return false;\n const colon = name.lastIndexOf(':');\n // `isQualifiedName()` splits at the LAST colon, so `@scope/pkg:x:Class`\n // would name the package `@scope/pkg:x`, which the lazy loader cannot\n // resolve. An alias's package part may not contain a colon.\n return (\n name.indexOf(':') === colon &&\n !/\\s/u.test(name.slice(0, colon)) &&\n /^[A-Za-z_$][\\w$]*$/u.test(name.slice(colon + 1))\n );\n}\n\n/** The declared aliases of one decorator/manifest config, as strings. */\nexport function readPreviousQualifiedNames(config: unknown): string[] {\n if (!config || typeof config !== 'object') return [];\n const declared = (config as { previousQualifiedNames?: unknown })\n .previousQualifiedNames;\n if (!Array.isArray(declared)) return [];\n return declared.filter((name): name is string => typeof name === 'string');\n}\n"],"mappings":";;;;;;;;;;;;AAuBA,SAAgB,oBACd,aACA,WACoB;CACpB,IAAI,CAAC,aACH,MAAM,IAAI,MACR,yEACoB,UAAU,sBAChC;CAEF,IAAI,CAAC,WACH,MAAM,IAAI,MACR,yEACsB,YAAY,oBACpC;CAEF,OAAO,GAAG,YAAY,GAAG;AAC3B;;;;;;;;;;;;AAuBA,SAAgB,mBAAmB,eAA4C;CAC7E,IAAI,CAAC,gBAAgB,aAAa,GAChC,MAAM,IAAI,MACR,4BAA4B,cAAc,8CAE5C;CAKF,MAAM,aAAa,cAAc,YAAY,GAAG;CAEhD,OAAO;EACL,aAAa,cAAc,MAAM,GAAG,UAAU;EAC9C,WAAW,cAAc,MAAM,aAAa,CAAC;CAC/C;AACF;;;;;;;;;;;;;;;;;AAkBA,SAAgB,gBAAgB,MAA0C;CACxE,IAAI,CAAC,QAAQ,OAAO,SAAS,UAC3B,OAAO;CAGT,MAAM,aAAa,KAAK,YAAY,GAAG;CACvC,IAAI,eAAe,IACjB,OAAO;CAGT,MAAM,cAAc,KAAK,MAAM,GAAG,UAAU;CAC5C,MAAM,YAAY,KAAK,MAAM,aAAa,CAAC;CAI3C,IAAI,CAAC,YAAY,WAAW,GAAG,KAAK,CAAC,YAAY,SAAS,GAAG,GAC3D,OAAO;CAIT,IAAI,CAAC,WACH,OAAO;CAGT,OAAO;AACT;;;;;;;;;;;;;AAcA,SAAgB,aAAa,MAAsB;CACjD,IAAI,gBAAgB,IAAI,GACtB,OAAO,mBAAmB,IAAI,CAAC,CAAC;CAElC,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,4BAA4B,MAAkC;CAC5E,IAAI,gBAAgB,IAAI,GACtB,OAAO,mBAAmB,IAAI,CAAC,CAAC;AAGpC;;;;;;;;;AAUA,SAAgB,oBAAoB,GAAW,GAAoB;CACjE,OAAO,MAAM;AACf;;;;;;;;;;;;AAaA,SAAgB,cACd,eACA,aACS;CACT,IAAI,CAAC,gBAAgB,aAAa,GAChC,OAAO;CAGT,OADe,mBAAmB,aAC3B,CAAA,CAAO,gBAAgB;AAChC;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,OACd,eACA,WACS;CACT,IAAI,CAAC,eACH,OAAO;CAET,OAAO,aAAa,aAAa,MAAM;AACzC;;;;;;;;;AAUA,SAAgB,2BAA2B,MAA+B;CACxE,IAAI,OAAO,SAAS,YAAY,CAAC,gBAAgB,IAAI,GAAG,OAAO;CAC/D,MAAM,QAAQ,KAAK,YAAY,GAAG;CAIlC,OACE,KAAK,QAAQ,GAAG,MAAM,SACtB,CAAC,MAAM,KAAK,KAAK,MAAM,GAAG,KAAK,CAAC,KAChC,sBAAsB,KAAK,KAAK,MAAM,QAAQ,CAAC,CAAC;AAEpD;;AAGA,SAAgB,2BAA2B,QAA2B;CACpE,IAAI,CAAC,UAAU,OAAO,WAAW,UAAU,OAAO,CAAC;CACnD,MAAM,WAAY,OACf;CACH,IAAI,CAAC,MAAM,QAAQ,QAAQ,GAAG,OAAO,CAAC;CACtC,OAAO,SAAS,QAAQ,SAAyB,OAAO,SAAS,QAAQ;AAC3E"}
1
+ {"version":3,"file":"qualified-names.js","names":[],"sources":["../../src/utils/qualified-names.ts"],"sourcesContent":["/**\n * Utilities for working with qualified class names in the SMRT framework.\n *\n * Qualified names follow the format: \"@package/name:ClassName\"\n * Example: \"@happyvertical/smrt-core:Product\"\n *\n * This format uniquely identifies classes across packages, eliminating\n * name collisions when multiple packages define classes with the same name.\n */\n\nimport { ConfigurationError } from '../errors.js';\nimport type { QualifiedClassName } from '../scanner/types.js';\n\n/**\n * Creates a qualified class name from package name and class name.\n *\n * @param packageName - The npm package name (e.g., \"@happyvertical/smrt-core\")\n * @param className - The class name (e.g., \"Product\")\n * @returns Qualified name in format \"@package/name:ClassName\"\n *\n * @example\n * createQualifiedName('@happyvertical/smrt-core', 'Product')\n * // Returns: \"@happyvertical/smrt-core:Product\"\n */\nexport function createQualifiedName(\n packageName: string,\n className: string,\n): QualifiedClassName {\n if (!packageName) {\n throw new Error(\n `Cannot create qualified name: packageName is required. ` +\n `Got className=\"${className}\" but no packageName.`,\n );\n }\n if (!className) {\n throw new Error(\n `Cannot create qualified name: className is required. ` +\n `Got packageName=\"${packageName}\" but no className.`,\n );\n }\n return `${packageName}:${className}` as QualifiedClassName;\n}\n\n/**\n * Result of parsing a qualified class name.\n */\nexport interface ParsedQualifiedName {\n /** The package name (e.g., \"@happyvertical/smrt-core\") */\n packageName: string;\n /** The class name (e.g., \"Product\") */\n className: string;\n}\n\n/**\n * Parses a qualified class name into its components.\n *\n * @param qualifiedName - The qualified name to parse\n * @returns Object with packageName and className\n * @throws Error if the name is not a valid qualified name\n *\n * @example\n * parseQualifiedName('@happyvertical/smrt-core:Product')\n * // Returns: { packageName: '@happyvertical/smrt-core', className: 'Product' }\n */\nexport function parseQualifiedName(qualifiedName: string): ParsedQualifiedName {\n if (!isQualifiedName(qualifiedName)) {\n throw new Error(\n `Invalid qualified name: \"${qualifiedName}\". ` +\n `Expected format: \"@package/name:ClassName\"`,\n );\n }\n\n // Use lastIndexOf to handle edge cases like \"@scope/pkg:Nested:Class\"\n // (though class names shouldn't have colons, this is defensive)\n const colonIndex = qualifiedName.lastIndexOf(':');\n\n return {\n packageName: qualifiedName.slice(0, colonIndex),\n className: qualifiedName.slice(colonIndex + 1),\n };\n}\n\n/**\n * Checks if a name is a valid qualified class name.\n *\n * A valid qualified name:\n * - Contains a colon (:) separating package and class\n * - Has a package name with a forward slash (scoped npm package)\n * - Has a non-empty class name after the colon\n *\n * @param name - The name to check\n * @returns true if the name is a valid qualified class name\n *\n * @example\n * isQualifiedName('@happyvertical/smrt-core:Product') // true\n * isQualifiedName('Product') // false\n * isQualifiedName('smrt-core:Product') // false (no @ scope)\n */\nexport function isQualifiedName(name: string): name is QualifiedClassName {\n if (!name || typeof name !== 'string') {\n return false;\n }\n\n const colonIndex = name.lastIndexOf(':');\n if (colonIndex === -1) {\n return false;\n }\n\n const packageName = name.slice(0, colonIndex);\n const className = name.slice(colonIndex + 1);\n\n // Package name must be a scoped npm package (contains @)\n // and have a path (contains /)\n if (!packageName.startsWith('@') || !packageName.includes('/')) {\n return false;\n }\n\n // Class name must be non-empty\n if (!className) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Whether a package name can be the package half of a qualified class name,\n * i.e. exactly what {@link isQualifiedName} requires: a scoped npm name\n * (`@scope/pkg`). The scope is a deliberate discriminator (#713): a bare\n * `word:Word` is never treated as qualified by lookups, STI discriminator\n * classification (`smrt db:sti-upgrade`), aliases or cross-package refs.\n */\nexport function isScopedPackageName(packageName: unknown): boolean {\n return (\n typeof packageName === 'string' &&\n packageName.startsWith('@') &&\n packageName.includes('/')\n );\n}\n\n/**\n * Fail at scan/generation time when a package name cannot produce a qualified\n * class name, instead of emitting `pkg:Class` keys that the registry's own\n * validator rejects later (`CONFIG_INVALID_ISOLATED_MANIFEST`, #3408).\n * `undefined` is allowed: no package name means no qualified names are generated.\n */\nexport function assertScopedPackageName(packageName: string | undefined): void {\n if (packageName === undefined || isScopedPackageName(packageName)) return;\n const bare = packageName.replace(/^@[^/]*\\/?/u, '');\n throw new ConfigurationError(\n `SMRT requires a scoped package name to qualify class names, but the package.json ` +\n `name is \"${packageName}\". Rename it to a scoped name such as \"@my-app/${bare}\". ` +\n `Qualified class names have the form \"@scope/package:ClassName\"; unscoped ` +\n `names are never treated as qualified.`,\n 'CONFIG_UNSCOPED_PACKAGE_NAME',\n { packageName },\n );\n}\n\n/**\n * Extracts just the class name from a qualified name or simple name.\n * Useful when you need the class name regardless of whether\n * the input is qualified or not.\n *\n * @param name - Qualified name or simple class name\n * @returns The simple class name\n *\n * @example\n * getClassName('@happyvertical/smrt-core:Product') // 'Product'\n * getClassName('Product') // 'Product'\n */\nexport function getClassName(name: string): string {\n if (isQualifiedName(name)) {\n return parseQualifiedName(name).className;\n }\n return name;\n}\n\n/**\n * Extracts the package name from a qualified name.\n * Returns undefined if the name is not qualified.\n *\n * @param name - Qualified name or simple class name\n * @returns The package name, or undefined if not qualified\n *\n * @example\n * getPackageFromQualifiedName('@happyvertical/smrt-core:Product')\n * // Returns: '@happyvertical/smrt-core'\n *\n * getPackageFromQualifiedName('Product')\n * // Returns: undefined\n */\nexport function getPackageFromQualifiedName(name: string): string | undefined {\n if (isQualifiedName(name)) {\n return parseQualifiedName(name).packageName;\n }\n return undefined;\n}\n\n/**\n * Compares two qualified names for equality.\n * Case-sensitive comparison.\n *\n * @param a - First qualified name\n * @param b - Second qualified name\n * @returns true if the qualified names are equal\n */\nexport function qualifiedNamesEqual(a: string, b: string): boolean {\n return a === b;\n}\n\n/**\n * Checks if a qualified name belongs to a specific package.\n *\n * @param qualifiedName - The qualified name to check\n * @param packageName - The package name to match against\n * @returns true if the qualified name is from the specified package\n *\n * @example\n * isFromPackage('@happyvertical/smrt-core:Product', '@happyvertical/smrt-core')\n * // Returns: true\n */\nexport function isFromPackage(\n qualifiedName: string,\n packageName: string,\n): boolean {\n if (!isQualifiedName(qualifiedName)) {\n return false;\n }\n const parsed = parseQualifiedName(qualifiedName);\n return parsed.packageName === packageName;\n}\n\n/**\n * Checks if a qualified name (like `_meta_type`) matches a short class name.\n * This is useful for STI filtering where the database stores qualified names\n * but code often uses short names for comparisons.\n *\n * @param qualifiedName - The qualified name or simple name to check (e.g., from `_meta_type`)\n * @param shortName - The short class name to match against\n * @returns true if the class name portion matches\n *\n * @example\n * ```typescript\n * // Filtering STI records by type\n * const meetings = events.filter(e => isType(e._meta_type, 'Meeting'));\n *\n * // Works with both qualified and simple names\n * isType('@happyvertical/praeco:MeetingRecap', 'MeetingRecap') // true\n * isType('@happyvertical/praeco:MeetingRecap', 'Article') // false\n * isType('MeetingRecap', 'MeetingRecap') // true (simple name passthrough)\n * ```\n */\nexport function isType(\n qualifiedName: string | null | undefined,\n shortName: string,\n): boolean {\n if (!qualifiedName) {\n return false;\n }\n return getClassName(qualifiedName) === shortName;\n}\n\n/**\n * A valid `previousQualifiedNames` entry: exactly what {@link isQualifiedName}\n * accepts (`@scope/package:ClassName` — the predicate eager lookups,\n * `resolveType()` and the lazy manifest loader all use), with an identifier\n * class name and exactly one colon. An unscoped `package:ClassName` is\n * refused: no qualified lookup treats it as qualified, so it could never\n * resolve (#3338).\n */\nexport function isQualifiedNameAliasFormat(name: unknown): name is string {\n if (typeof name !== 'string' || !isQualifiedName(name)) return false;\n const colon = name.lastIndexOf(':');\n // `isQualifiedName()` splits at the LAST colon, so `@scope/pkg:x:Class`\n // would name the package `@scope/pkg:x`, which the lazy loader cannot\n // resolve. An alias's package part may not contain a colon.\n return (\n name.indexOf(':') === colon &&\n !/\\s/u.test(name.slice(0, colon)) &&\n /^[A-Za-z_$][\\w$]*$/u.test(name.slice(colon + 1))\n );\n}\n\n/** The declared aliases of one decorator/manifest config, as strings. */\nexport function readPreviousQualifiedNames(config: unknown): string[] {\n if (!config || typeof config !== 'object') return [];\n const declared = (config as { previousQualifiedNames?: unknown })\n .previousQualifiedNames;\n if (!Array.isArray(declared)) return [];\n return declared.filter((name): name is string => typeof name === 'string');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,oBACd,aACA,WACoB;CACpB,IAAI,CAAC,aACH,MAAM,IAAI,MACR,yEACoB,UAAU,sBAChC;CAEF,IAAI,CAAC,WACH,MAAM,IAAI,MACR,yEACsB,YAAY,oBACpC;CAEF,OAAO,GAAG,YAAY,GAAG;AAC3B;;;;;;;;;;;;AAuBA,SAAgB,mBAAmB,eAA4C;CAC7E,IAAI,CAAC,gBAAgB,aAAa,GAChC,MAAM,IAAI,MACR,4BAA4B,cAAc,8CAE5C;CAKF,MAAM,aAAa,cAAc,YAAY,GAAG;CAEhD,OAAO;EACL,aAAa,cAAc,MAAM,GAAG,UAAU;EAC9C,WAAW,cAAc,MAAM,aAAa,CAAC;CAC/C;AACF;;;;;;;;;;;;;;;;;AAkBA,SAAgB,gBAAgB,MAA0C;CACxE,IAAI,CAAC,QAAQ,OAAO,SAAS,UAC3B,OAAO;CAGT,MAAM,aAAa,KAAK,YAAY,GAAG;CACvC,IAAI,eAAe,IACjB,OAAO;CAGT,MAAM,cAAc,KAAK,MAAM,GAAG,UAAU;CAC5C,MAAM,YAAY,KAAK,MAAM,aAAa,CAAC;CAI3C,IAAI,CAAC,YAAY,WAAW,GAAG,KAAK,CAAC,YAAY,SAAS,GAAG,GAC3D,OAAO;CAIT,IAAI,CAAC,WACH,OAAO;CAGT,OAAO;AACT;;;;;;;;AASA,SAAgB,oBAAoB,aAA+B;CACjE,OACE,OAAO,gBAAgB,YACvB,YAAY,WAAW,GAAG,KAC1B,YAAY,SAAS,GAAG;AAE5B;;;;;;;AAQA,SAAgB,wBAAwB,aAAuC;CAC7E,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,WAAW,GAAG;CAEnE,MAAM,IAAI,mBACR,6FACc,YAAY,iDAHf,YAAY,QAAQ,eAAe,EAG6B,EAAK,oHAGhF,gCACA,EAAE,YAAY,CAChB;AACF;;;;;;;;;;;;;AAcA,SAAgB,aAAa,MAAsB;CACjD,IAAI,gBAAgB,IAAI,GACtB,OAAO,mBAAmB,IAAI,CAAC,CAAC;CAElC,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,4BAA4B,MAAkC;CAC5E,IAAI,gBAAgB,IAAI,GACtB,OAAO,mBAAmB,IAAI,CAAC,CAAC;AAGpC;;;;;;;;;AAUA,SAAgB,oBAAoB,GAAW,GAAoB;CACjE,OAAO,MAAM;AACf;;;;;;;;;;;;AAaA,SAAgB,cACd,eACA,aACS;CACT,IAAI,CAAC,gBAAgB,aAAa,GAChC,OAAO;CAGT,OADe,mBAAmB,aAC3B,CAAA,CAAO,gBAAgB;AAChC;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,OACd,eACA,WACS;CACT,IAAI,CAAC,eACH,OAAO;CAET,OAAO,aAAa,aAAa,MAAM;AACzC;;;;;;;;;AAUA,SAAgB,2BAA2B,MAA+B;CACxE,IAAI,OAAO,SAAS,YAAY,CAAC,gBAAgB,IAAI,GAAG,OAAO;CAC/D,MAAM,QAAQ,KAAK,YAAY,GAAG;CAIlC,OACE,KAAK,QAAQ,GAAG,MAAM,SACtB,CAAC,MAAM,KAAK,KAAK,MAAM,GAAG,KAAK,CAAC,KAChC,sBAAsB,KAAK,KAAK,MAAM,QAAQ,CAAC,CAAC;AAEpD;;AAGA,SAAgB,2BAA2B,QAA2B;CACpE,IAAI,CAAC,UAAU,OAAO,WAAW,UAAU,OAAO,CAAC;CACnD,MAAM,WAAY,OACf;CACH,IAAI,CAAC,MAAM,QAAQ,QAAQ,GAAG,OAAO,CAAC;CACtC,OAAO,SAAS,QAAQ,SAAyB,OAAO,SAAS,QAAQ;AAC3E"}
@@ -22,9 +22,10 @@
22
22
  * station principal reading only its own rows of a table the office can
23
23
  * read in full). Unregistered, behavior is unchanged from pre-#3020.
24
24
  * - **Database resolution**: the route anchors on the project's first
25
- * generated collection (alphabetical) via the consumer's existing
26
- * `getCollection()` helper, inheriting its configuration, request-scoped
27
- * database support and system-table bootstrap. Multi-database projects
25
+ * generated collection (alphabetical) via the embedded access prelude's
26
+ * `getCollection()` (the app runtime's request-scoped accessor),
27
+ * inheriting its configuration, request-scoped database support and
28
+ * system-table bootstrap. Multi-database projects
28
29
  * (per-object `db` overrides) see the anchor collection's feed.
29
30
  * - Cleanup rides the existing generated-route sweep: the emitted file
30
31
  * starts with {@link AUTO_GENERATED_ROUTE_HEADER}.
@@ -1 +1 @@
1
- {"version":3,"file":"changes-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/changes-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAIH,OAAO,KAAK,EAEV,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAWjE;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,mBAAmB,GAC5B,MAAM,GAAG,IAAI,CASf;AAED,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAKT;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CA2BT"}
1
+ {"version":3,"file":"changes-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/changes-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAIH,OAAO,KAAK,EAEV,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAWjE;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,mBAAmB,GAC5B,MAAM,GAAG,IAAI,CASf;AAED,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAKT;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CA6BT"}
@@ -1,5 +1,5 @@
1
1
  import { AUTO_GENERATED_ROUTE_HEADER } from "./route-header.js";
2
- import { resolveSvelteKitConfigImport } from "./sveltekit-config-import.js";
2
+ import { generateCollectionAccessImports } from "./sveltekit-config-import.js";
3
3
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  //#region src/vite-plugin/changes-route.ts
@@ -27,9 +27,10 @@ import { join } from "node:path";
27
27
  * station principal reading only its own rows of a table the office can
28
28
  * read in full). Unregistered, behavior is unchanged from pre-#3020.
29
29
  * - **Database resolution**: the route anchors on the project's first
30
- * generated collection (alphabetical) via the consumer's existing
31
- * `getCollection()` helper, inheriting its configuration, request-scoped
32
- * database support and system-table bootstrap. Multi-database projects
30
+ * generated collection (alphabetical) via the embedded access prelude's
31
+ * `getCollection()` (the app runtime's request-scoped accessor),
32
+ * inheriting its configuration, request-scoped database support and
33
+ * system-table bootstrap. Multi-database projects
33
34
  * (per-object `db` overrides) see the anchor collection's feed.
34
35
  * - Cleanup rides the existing generated-route sweep: the emitted file
35
36
  * starts with {@link AUTO_GENERATED_ROUTE_HEADER}.
@@ -79,14 +80,14 @@ function generateChangesRoute(projectRoot, manifest, options) {
79
80
  return false;
80
81
  }
81
82
  const routeDir = join(projectRoot, options.routesDir, "_changes");
82
- const content = generateChangesRouteTemplate(anchorClassName, manifestHasTenantScopedObject(manifest), resolveSvelteKitConfigImport(projectRoot, routeDir, options));
83
+ const content = generateChangesRouteTemplate(anchorClassName, manifestHasTenantScopedObject(manifest), generateCollectionAccessImports(projectRoot, routeDir, options, ["getCollection"]));
83
84
  if (!existsSync(routeDir)) mkdirSync(routeDir, { recursive: true });
84
85
  const filePath = join(routeDir, "+server.ts");
85
86
  writeFileSync(filePath, content, "utf-8");
86
87
  console.log(`[smrt] Generated: ${filePath}`);
87
88
  return true;
88
89
  }
89
- function generateChangesRouteTemplate(anchorClassName, tenantScoped, configImport) {
90
+ function generateChangesRouteTemplate(anchorClassName, tenantScoped, collectionAccessImports) {
90
91
  return `${AUTO_GENERATED_ROUTE_HEADER}
91
92
  // DO NOT EDIT - changes will be overwritten
92
93
  //
@@ -109,8 +110,8 @@ function generateChangesRouteTemplate(anchorClassName, tenantScoped, configImpor
109
110
 
110
111
  import { error, json } from '@sveltejs/kit';
111
112
  import { getAuthorizedTenantScopedChangesSince } from '@happyvertical/smrt-core';
112
- import { getCollection } from '${configImport}';
113
113
  import type { RequestHandler } from './$types';
114
+ ${collectionAccessImports}
114
115
 
115
116
  // Fail-closed authorization (#1540): the change feed spans every table, so
116
117
  // it is never public — an authenticated principal on \`locals\` is required.
@@ -1 +1 @@
1
- {"version":3,"file":"changes-route.js","names":[],"sources":["../../src/vite-plugin/changes-route.ts"],"sourcesContent":["/**\n * SvelteKit `_changes` route generation for the change feed (issue #1758).\n *\n * Emits `{routesDir}/_changes/+server.ts` — an auth-guarded, tenant-scoped\n * GET endpoint over the `_smrt_changes` log, part of the client/mobile sync\n * contract (PRD #1755). Kept in its own module so `sveltekit-generator.ts`\n * only carries a one-line registration.\n *\n * Design notes:\n * - **Fail-closed auth** (#1540 posture): the handler requires an\n * authenticated principal on `locals`. The feed spans every table, so\n * per-model `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scoping**: when the project has tenant-scoped objects, the\n * route establishes tenant context from `locals` exactly like generated\n * collection routes, then reads through\n * `getAuthorizedTenantScopedChangesSince()` — a tenant only ever sees its\n * own changes plus global rows.\n * - **Table/row authorization** (#3020): the same call additionally applies\n * the consumer-supplied `authorizeChangeFeed`/`isChangeFeedEntryVisible`\n * hooks (`@happyvertical/smrt-core`'s `change-feed-authz.ts`), if\n * registered — a table-level and row-level seam beyond tenant scope (e.g. a\n * station principal reading only its own rows of a table the office can\n * read in full). Unregistered, behavior is unchanged from pre-#3020.\n * - **Database resolution**: the route anchors on the project's first\n * generated collection (alphabetical) via the consumer's existing\n * `getCollection()` helper, inheriting its configuration, request-scoped\n * database support and system-table bootstrap. Multi-database projects\n * (per-object `db` overrides) see the anchor collection's feed.\n * - Cleanup rides the existing generated-route sweep: the emitted file\n * starts with {@link AUTO_GENERATED_ROUTE_HEADER}.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types';\nimport { AUTO_GENERATED_ROUTE_HEADER } from './route-header.js';\nimport { resolveSvelteKitConfigImport } from './sveltekit-config-import.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\n\n/**\n * Mirrors `isCollectionClass` in `sveltekit-generator.ts` (module-private\n * there): collection classes share route paths with their item class and\n * never anchor routes themselves.\n */\nfunction isCollectionDefinition(objectDef: SmartObjectDefinition): boolean {\n return objectDef.extends === 'SmrtCollection' || !!objectDef.extendsTypeArg;\n}\n\n/**\n * Pick the class the route resolves its database through: the first\n * non-collection object by sorted manifest key, for determinism across\n * builds.\n *\n * Returns the manifest **registry key** verbatim (which may be\n * package-qualified, e.g. `@happyvertical/smrt-ledgers:Account`) — the emitted\n * route passes it straight to `getCollection()`, exactly as the generated CRUD\n * routes do. Collapsing it to a simple class name would resolve ambiguously\n * when two loaded packages declare the same simple name (mirrors the #1778\n * verbatim-key fix and the sync-apply route's `registryKey`).\n */\nexport function resolveAnchorClassName(\n manifest: SmartObjectManifest,\n): string | null {\n const sortedNames = Object.keys(manifest.objects).sort();\n for (const name of sortedNames) {\n const def = manifest.objects[name];\n if (def && !isCollectionDefinition(def)) {\n return name;\n }\n }\n return null;\n}\n\nexport function manifestHasTenantScopedObject(\n manifest: SmartObjectManifest,\n): boolean {\n return Object.values(manifest.objects).some(\n (def) =>\n !isCollectionDefinition(def) && !!def.decoratorConfig?.tenantScoped,\n );\n}\n\n/**\n * Generate the `_changes/+server.ts` route. Returns true when a route was\n * written. Disabled with `sveltekit: { changesRoute: { enabled: false } }`;\n * skipped (with a log line) when the manifest has no objects to anchor the\n * database on.\n */\nexport function generateChangesRoute(\n projectRoot: string,\n manifest: SmartObjectManifest,\n options: SvelteKitOptions,\n): boolean {\n if (options.changesRoute?.enabled === false) {\n return false;\n }\n\n const anchorClassName = resolveAnchorClassName(manifest);\n if (!anchorClassName) {\n console.log(\n '[smrt] Skipping _changes route - no SMRT objects to anchor the database on',\n );\n return false;\n }\n\n const routeDir = join(projectRoot, options.routesDir, '_changes');\n const content = generateChangesRouteTemplate(\n anchorClassName,\n manifestHasTenantScopedObject(manifest),\n resolveSvelteKitConfigImport(projectRoot, routeDir, options),\n );\n\n if (!existsSync(routeDir)) {\n mkdirSync(routeDir, { recursive: true });\n }\n const filePath = join(routeDir, '+server.ts');\n writeFileSync(filePath, content, 'utf-8');\n console.log(`[smrt] Generated: ${filePath}`);\n return true;\n}\n\nfunction generateChangesRouteTemplate(\n anchorClassName: string,\n tenantScoped: boolean,\n configImport: string,\n): string {\n const tenantHelper = tenantScoped\n ? `\nimport { enterTenantContext, hasTenantContext } from '@happyvertical/smrt-tenancy';\n\nfunction establishTenantContext(locals: unknown): void {\n if (hasTenantContext()) return;\n if (!locals || typeof locals !== 'object') return;\n const l = locals as Record<string, unknown>;\n const user = l.user as Record<string, unknown> | undefined;\n const session = l.session as Record<string, unknown> | undefined;\n const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId;\n if (typeof tenantId === 'string' && tenantId) {\n enterTenantContext({ tenantId });\n }\n}\n`\n : '';\n const tenantCall = tenantScoped ? '\\n establishTenantContext(locals);' : '';\n\n return `${AUTO_GENERATED_ROUTE_HEADER}\n// DO NOT EDIT - changes will be overwritten\n//\n// GET /_changes — cursor read over the _smrt_changes change feed (#1758).\n// Part of the client/mobile sync contract: poll with the returned cursor to\n// observe every committed framework save/delete (deletes are tombstones)\n// exactly once. Query params: since (cursor, default 0), tables\n// (comma-separated), limit. A response with resyncRequired: true (still\n// HTTP 200 — protocol state, not an error) means the cursor cannot be\n// served incrementally (pruned or foreign) and the client must re-fetch\n// in full before resuming polling from resyncCursor.\n//\n// Table/row authorization (#3020): beyond tenant scope, the read additionally\n// applies the consumer-supplied \\`authorizeChangeFeed\\`/\n// \\`isChangeFeedEntryVisible\\` hooks, if registered (see\n// \\`@happyvertical/smrt-core\\`'s \\`change-feed-authz.ts\\`). A denied table or row\n// is never returned — filtering happens before the response is built, not on\n// the client — and the cursor still advances past a denied entry so polling\n// never stalls or re-requests it.\n\nimport { error, json } from '@sveltejs/kit';\nimport { getAuthorizedTenantScopedChangesSince } from '@happyvertical/smrt-core';\nimport { getCollection } from '${configImport}';\nimport type { RequestHandler } from './$types';\n\n// Fail-closed authorization (#1540): the change feed spans every table, so\n// it is never public — an authenticated principal on \\`locals\\` is required.\nfunction hasAuthenticatedPrincipal(locals: unknown): boolean {\n if (!locals || typeof locals !== 'object') return false;\n const l = locals as Record<string, unknown>;\n const isResolvedPrincipal = (v: unknown) =>\n typeof v === 'object' && v !== null;\n return (\n isResolvedPrincipal(l.user) ||\n isResolvedPrincipal(l.session) ||\n l.smrtAuth === true\n );\n}\n\nfunction requireRouteAuth(locals: unknown): void {\n if (!hasAuthenticatedPrincipal(locals)) {\n throw error(401, 'Authentication required');\n }\n}\n${tenantHelper}\nexport const GET: RequestHandler = async ({ locals, url, request }) => {\n requireRouteAuth(locals);${tenantCall}\n\n const since = Number(url.searchParams.get('since') ?? '0');\n if (!Number.isFinite(since) || since < 0) {\n throw error(400, \"'since' must be a non-negative number\");\n }\n\n let limit: number | undefined;\n const limitParam = url.searchParams.get('limit');\n if (limitParam !== null) {\n limit = Number(limitParam);\n if (!Number.isFinite(limit) || limit < 1) {\n throw error(400, \"'limit' must be a positive number\");\n }\n }\n\n const tablesParam = url.searchParams.get('tables');\n const tables = tablesParam\n ? tablesParam\n .split(',')\n .map((table) => table.trim())\n .filter(Boolean)\n : undefined;\n\n // The feed lives in the project's database; anchor on the\n // ${anchorClassName} collection to reuse its configured connection.\n const collection = await getCollection('${anchorClassName}');\n const page = await getAuthorizedTenantScopedChangesSince(collection.db, {\n since,\n tables,\n limit,\n locals,\n request,\n });\n return json(page);\n};\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CA,SAAS,uBAAuB,WAA2C;CACzE,OAAO,UAAU,YAAY,oBAAoB,CAAC,CAAC,UAAU;AAC/D;;;;;;;;;;;;;AAcA,SAAgB,uBACd,UACe;CACf,MAAM,cAAc,OAAO,KAAK,SAAS,OAAO,CAAC,CAAC,KAAK;CACvD,KAAK,MAAM,QAAQ,aAAa;EAC9B,MAAM,MAAM,SAAS,QAAQ;EAC7B,IAAI,OAAO,CAAC,uBAAuB,GAAG,GACpC,OAAO;CAEX;CACA,OAAO;AACT;AAEA,SAAgB,8BACd,UACS;CACT,OAAO,OAAO,OAAO,SAAS,OAAO,CAAC,CAAC,MACpC,QACC,CAAC,uBAAuB,GAAG,KAAK,CAAC,CAAC,IAAI,iBAAiB,YAC3D;AACF;;;;;;;AAQA,SAAgB,qBACd,aACA,UACA,SACS;CACT,IAAI,QAAQ,cAAc,YAAY,OACpC,OAAO;CAGT,MAAM,kBAAkB,uBAAuB,QAAQ;CACvD,IAAI,CAAC,iBAAiB;EACpB,QAAQ,IACN,4EACF;EACA,OAAO;CACT;CAEA,MAAM,WAAW,KAAK,aAAa,QAAQ,WAAW,UAAU;CAChE,MAAM,UAAU,6BACd,iBACA,8BAA8B,QAAQ,GACtC,6BAA6B,aAAa,UAAU,OAAO,CAC7D;CAEA,IAAI,CAAC,WAAW,QAAQ,GACtB,UAAU,UAAU,EAAE,WAAW,KAAK,CAAC;CAEzC,MAAM,WAAW,KAAK,UAAU,YAAY;CAC5C,cAAc,UAAU,SAAS,OAAO;CACxC,QAAQ,IAAI,qBAAqB,UAAU;CAC3C,OAAO;AACT;AAEA,SAAS,6BACP,iBACA,cACA,cACQ;CAoBR,OAAO,GAAG,4BAA4B;;;;;;;;;;;;;;;;;;;;;;iCAsBP,aAAa;;;;;;;;;;;;;;;;;;;;;;EAzCvB,eACjB;;;;;;;;;;;;;;IAeA,GA+CS;;6BA9CM,eAAe,wCAAwC,GAgDpC;;;;;;;;;;;;;;;;;;;;;;;;;OAyBjC,gBAAgB;4CACqB,gBAAgB;;;;;;;;;;;AAW5D"}
1
+ {"version":3,"file":"changes-route.js","names":[],"sources":["../../src/vite-plugin/changes-route.ts"],"sourcesContent":["/**\n * SvelteKit `_changes` route generation for the change feed (issue #1758).\n *\n * Emits `{routesDir}/_changes/+server.ts` — an auth-guarded, tenant-scoped\n * GET endpoint over the `_smrt_changes` log, part of the client/mobile sync\n * contract (PRD #1755). Kept in its own module so `sveltekit-generator.ts`\n * only carries a one-line registration.\n *\n * Design notes:\n * - **Fail-closed auth** (#1540 posture): the handler requires an\n * authenticated principal on `locals`. The feed spans every table, so\n * per-model `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scoping**: when the project has tenant-scoped objects, the\n * route establishes tenant context from `locals` exactly like generated\n * collection routes, then reads through\n * `getAuthorizedTenantScopedChangesSince()` — a tenant only ever sees its\n * own changes plus global rows.\n * - **Table/row authorization** (#3020): the same call additionally applies\n * the consumer-supplied `authorizeChangeFeed`/`isChangeFeedEntryVisible`\n * hooks (`@happyvertical/smrt-core`'s `change-feed-authz.ts`), if\n * registered — a table-level and row-level seam beyond tenant scope (e.g. a\n * station principal reading only its own rows of a table the office can\n * read in full). Unregistered, behavior is unchanged from pre-#3020.\n * - **Database resolution**: the route anchors on the project's first\n * generated collection (alphabetical) via the embedded access prelude's\n * `getCollection()` (the app runtime's request-scoped accessor),\n * inheriting its configuration, request-scoped database support and\n * system-table bootstrap. Multi-database projects\n * (per-object `db` overrides) see the anchor collection's feed.\n * - Cleanup rides the existing generated-route sweep: the emitted file\n * starts with {@link AUTO_GENERATED_ROUTE_HEADER}.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types';\nimport { AUTO_GENERATED_ROUTE_HEADER } from './route-header.js';\nimport { generateCollectionAccessImports } from './sveltekit-config-import.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\n\n/**\n * Mirrors `isCollectionClass` in `sveltekit-generator.ts` (module-private\n * there): collection classes share route paths with their item class and\n * never anchor routes themselves.\n */\nfunction isCollectionDefinition(objectDef: SmartObjectDefinition): boolean {\n return objectDef.extends === 'SmrtCollection' || !!objectDef.extendsTypeArg;\n}\n\n/**\n * Pick the class the route resolves its database through: the first\n * non-collection object by sorted manifest key, for determinism across\n * builds.\n *\n * Returns the manifest **registry key** verbatim (which may be\n * package-qualified, e.g. `@happyvertical/smrt-ledgers:Account`) — the emitted\n * route passes it straight to `getCollection()`, exactly as the generated CRUD\n * routes do. Collapsing it to a simple class name would resolve ambiguously\n * when two loaded packages declare the same simple name (mirrors the #1778\n * verbatim-key fix and the sync-apply route's `registryKey`).\n */\nexport function resolveAnchorClassName(\n manifest: SmartObjectManifest,\n): string | null {\n const sortedNames = Object.keys(manifest.objects).sort();\n for (const name of sortedNames) {\n const def = manifest.objects[name];\n if (def && !isCollectionDefinition(def)) {\n return name;\n }\n }\n return null;\n}\n\nexport function manifestHasTenantScopedObject(\n manifest: SmartObjectManifest,\n): boolean {\n return Object.values(manifest.objects).some(\n (def) =>\n !isCollectionDefinition(def) && !!def.decoratorConfig?.tenantScoped,\n );\n}\n\n/**\n * Generate the `_changes/+server.ts` route. Returns true when a route was\n * written. Disabled with `sveltekit: { changesRoute: { enabled: false } }`;\n * skipped (with a log line) when the manifest has no objects to anchor the\n * database on.\n */\nexport function generateChangesRoute(\n projectRoot: string,\n manifest: SmartObjectManifest,\n options: SvelteKitOptions,\n): boolean {\n if (options.changesRoute?.enabled === false) {\n return false;\n }\n\n const anchorClassName = resolveAnchorClassName(manifest);\n if (!anchorClassName) {\n console.log(\n '[smrt] Skipping _changes route - no SMRT objects to anchor the database on',\n );\n return false;\n }\n\n const routeDir = join(projectRoot, options.routesDir, '_changes');\n const content = generateChangesRouteTemplate(\n anchorClassName,\n manifestHasTenantScopedObject(manifest),\n generateCollectionAccessImports(projectRoot, routeDir, options, [\n 'getCollection',\n ]),\n );\n\n if (!existsSync(routeDir)) {\n mkdirSync(routeDir, { recursive: true });\n }\n const filePath = join(routeDir, '+server.ts');\n writeFileSync(filePath, content, 'utf-8');\n console.log(`[smrt] Generated: ${filePath}`);\n return true;\n}\n\nfunction generateChangesRouteTemplate(\n anchorClassName: string,\n tenantScoped: boolean,\n collectionAccessImports: string,\n): string {\n const tenantHelper = tenantScoped\n ? `\nimport { enterTenantContext, hasTenantContext } from '@happyvertical/smrt-tenancy';\n\nfunction establishTenantContext(locals: unknown): void {\n if (hasTenantContext()) return;\n if (!locals || typeof locals !== 'object') return;\n const l = locals as Record<string, unknown>;\n const user = l.user as Record<string, unknown> | undefined;\n const session = l.session as Record<string, unknown> | undefined;\n const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId;\n if (typeof tenantId === 'string' && tenantId) {\n enterTenantContext({ tenantId });\n }\n}\n`\n : '';\n const tenantCall = tenantScoped ? '\\n establishTenantContext(locals);' : '';\n\n return `${AUTO_GENERATED_ROUTE_HEADER}\n// DO NOT EDIT - changes will be overwritten\n//\n// GET /_changes — cursor read over the _smrt_changes change feed (#1758).\n// Part of the client/mobile sync contract: poll with the returned cursor to\n// observe every committed framework save/delete (deletes are tombstones)\n// exactly once. Query params: since (cursor, default 0), tables\n// (comma-separated), limit. A response with resyncRequired: true (still\n// HTTP 200 — protocol state, not an error) means the cursor cannot be\n// served incrementally (pruned or foreign) and the client must re-fetch\n// in full before resuming polling from resyncCursor.\n//\n// Table/row authorization (#3020): beyond tenant scope, the read additionally\n// applies the consumer-supplied \\`authorizeChangeFeed\\`/\n// \\`isChangeFeedEntryVisible\\` hooks, if registered (see\n// \\`@happyvertical/smrt-core\\`'s \\`change-feed-authz.ts\\`). A denied table or row\n// is never returned — filtering happens before the response is built, not on\n// the client — and the cursor still advances past a denied entry so polling\n// never stalls or re-requests it.\n\nimport { error, json } from '@sveltejs/kit';\nimport { getAuthorizedTenantScopedChangesSince } from '@happyvertical/smrt-core';\nimport type { RequestHandler } from './$types';\n${collectionAccessImports}\n\n// Fail-closed authorization (#1540): the change feed spans every table, so\n// it is never public — an authenticated principal on \\`locals\\` is required.\nfunction hasAuthenticatedPrincipal(locals: unknown): boolean {\n if (!locals || typeof locals !== 'object') return false;\n const l = locals as Record<string, unknown>;\n const isResolvedPrincipal = (v: unknown) =>\n typeof v === 'object' && v !== null;\n return (\n isResolvedPrincipal(l.user) ||\n isResolvedPrincipal(l.session) ||\n l.smrtAuth === true\n );\n}\n\nfunction requireRouteAuth(locals: unknown): void {\n if (!hasAuthenticatedPrincipal(locals)) {\n throw error(401, 'Authentication required');\n }\n}\n${tenantHelper}\nexport const GET: RequestHandler = async ({ locals, url, request }) => {\n requireRouteAuth(locals);${tenantCall}\n\n const since = Number(url.searchParams.get('since') ?? '0');\n if (!Number.isFinite(since) || since < 0) {\n throw error(400, \"'since' must be a non-negative number\");\n }\n\n let limit: number | undefined;\n const limitParam = url.searchParams.get('limit');\n if (limitParam !== null) {\n limit = Number(limitParam);\n if (!Number.isFinite(limit) || limit < 1) {\n throw error(400, \"'limit' must be a positive number\");\n }\n }\n\n const tablesParam = url.searchParams.get('tables');\n const tables = tablesParam\n ? tablesParam\n .split(',')\n .map((table) => table.trim())\n .filter(Boolean)\n : undefined;\n\n // The feed lives in the project's database; anchor on the\n // ${anchorClassName} collection to reuse its configured connection.\n const collection = await getCollection('${anchorClassName}');\n const page = await getAuthorizedTenantScopedChangesSince(collection.db, {\n since,\n tables,\n limit,\n locals,\n request,\n });\n return json(page);\n};\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDA,SAAS,uBAAuB,WAA2C;CACzE,OAAO,UAAU,YAAY,oBAAoB,CAAC,CAAC,UAAU;AAC/D;;;;;;;;;;;;;AAcA,SAAgB,uBACd,UACe;CACf,MAAM,cAAc,OAAO,KAAK,SAAS,OAAO,CAAC,CAAC,KAAK;CACvD,KAAK,MAAM,QAAQ,aAAa;EAC9B,MAAM,MAAM,SAAS,QAAQ;EAC7B,IAAI,OAAO,CAAC,uBAAuB,GAAG,GACpC,OAAO;CAEX;CACA,OAAO;AACT;AAEA,SAAgB,8BACd,UACS;CACT,OAAO,OAAO,OAAO,SAAS,OAAO,CAAC,CAAC,MACpC,QACC,CAAC,uBAAuB,GAAG,KAAK,CAAC,CAAC,IAAI,iBAAiB,YAC3D;AACF;;;;;;;AAQA,SAAgB,qBACd,aACA,UACA,SACS;CACT,IAAI,QAAQ,cAAc,YAAY,OACpC,OAAO;CAGT,MAAM,kBAAkB,uBAAuB,QAAQ;CACvD,IAAI,CAAC,iBAAiB;EACpB,QAAQ,IACN,4EACF;EACA,OAAO;CACT;CAEA,MAAM,WAAW,KAAK,aAAa,QAAQ,WAAW,UAAU;CAChE,MAAM,UAAU,6BACd,iBACA,8BAA8B,QAAQ,GACtC,gCAAgC,aAAa,UAAU,SAAS,CAC9D,eACF,CAAC,CACH;CAEA,IAAI,CAAC,WAAW,QAAQ,GACtB,UAAU,UAAU,EAAE,WAAW,KAAK,CAAC;CAEzC,MAAM,WAAW,KAAK,UAAU,YAAY;CAC5C,cAAc,UAAU,SAAS,OAAO;CACxC,QAAQ,IAAI,qBAAqB,UAAU;CAC3C,OAAO;AACT;AAEA,SAAS,6BACP,iBACA,cACA,yBACQ;CAoBR,OAAO,GAAG,4BAA4B;;;;;;;;;;;;;;;;;;;;;;;EAuBtC,wBAAwB;;;;;;;;;;;;;;;;;;;;;EA1CH,eACjB;;;;;;;;;;;;;;IAeA,GA+CS;;6BA9CM,eAAe,wCAAwC,GAgDpC;;;;;;;;;;;;;;;;;;;;;;;;;OAyBjC,gBAAgB;4CACqB,gBAAgB;;;;;;;;;;;AAW5D"}
@@ -1 +1 @@
1
- {"version":3,"file":"dev-plane-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/dev-plane-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE,kFAAkF;AAClF,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAUlE;AAED,yEAAyE;AACzE,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAKT;AAED,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAyBT"}
1
+ {"version":3,"file":"dev-plane-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/dev-plane-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE,kFAAkF;AAClF,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAUlE;AAED,yEAAyE;AACzE,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAKT;AAED,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CA2BT"}
@@ -1,5 +1,5 @@
1
1
  import { AUTO_GENERATED_ROUTE_HEADER } from "./route-header.js";
2
- import { resolveSvelteKitConfigImport } from "./sveltekit-config-import.js";
2
+ import { generateCollectionAccessImports } from "./sveltekit-config-import.js";
3
3
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { createRequire } from "node:module";
5
5
  import { join } from "node:path";
@@ -38,11 +38,11 @@ function generateDevPlaneRoute(projectRoot, options) {
38
38
  const routeDir = join(projectRoot, options.routesDir, "_dev", "[...tool]");
39
39
  if (!existsSync(routeDir)) mkdirSync(routeDir, { recursive: true });
40
40
  const filePath = join(routeDir, "+server.ts");
41
- writeFileSync(filePath, generateDevPlaneRouteTemplate(resolveSvelteKitConfigImport(projectRoot, routeDir, options)), "utf-8");
41
+ writeFileSync(filePath, generateDevPlaneRouteTemplate(generateCollectionAccessImports(projectRoot, routeDir, options, ["getSmrtConfig"])), "utf-8");
42
42
  console.log(`[smrt] Generated: ${filePath}`);
43
43
  return true;
44
44
  }
45
- function generateDevPlaneRouteTemplate(configImport) {
45
+ function generateDevPlaneRouteTemplate(collectionAccessImports) {
46
46
  return `${AUTO_GENERATED_ROUTE_HEADER}
47
47
  // DO NOT EDIT - changes will be overwritten
48
48
  //
@@ -55,7 +55,7 @@ import { dev } from '$app/environment';
55
55
  import { error } from '@sveltejs/kit';
56
56
  import type { RequestHandler } from './$types';
57
57
  import { createDevPlane, type DevPlane } from '@happyvertical/smrt-dev-mcp/dev-plane';
58
- import { getSmrtConfig } from '${configImport}';
58
+ ${collectionAccessImports}
59
59
 
60
60
  let plane: DevPlane | null = null;
61
61