@happyvertical/smrt-core 0.45.0 → 0.45.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +81 -289
- package/agents/build-knowledge.md +92 -0
- package/agents/memory.md +4 -0
- package/agents/object-runtime.md +83 -0
- package/agents/schema-paths.md +186 -476
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +25 -7
- package/dist/generators/mcp.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/knowledge-discovery.d.ts +26 -0
- package/dist/knowledge-discovery.d.ts.map +1 -0
- package/dist/knowledge-discovery.js +54 -0
- package/dist/knowledge-discovery.js.map +1 -0
- package/dist/knowledge.d.ts +1 -0
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +30 -12
- package/dist/knowledge.js.map +1 -1
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/registry/types.d.ts +11 -6
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +47 -35
- package/dist/vite-plugin/index.d.ts +10 -0
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +4 -0
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/resources-route.d.ts +18 -0
- package/dist/vite-plugin/resources-route.d.ts.map +1 -0
- package/dist/vite-plugin/resources-route.js +154 -0
- package/dist/vite-plugin/resources-route.js.map +1 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts +10 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +2 -0
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/package.json +4 -4
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,22 +3,24 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-core",
|
|
6
|
-
"packageVersion": "0.45.
|
|
6
|
+
"packageVersion": "0.45.2",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
12
|
-
"agents": "
|
|
10
|
+
"manifest": "4692b171eebba607cb15e3565ce6d2aee8b73e69f858e9eb60676d2013b542c8",
|
|
11
|
+
"packageJson": "6cf3ab0ed9ae68c7ef911d01cb53b71fc55c39d058150ea17f0935f0468fa947",
|
|
12
|
+
"agents": "c8f8d790e7b584166a7e45a71fb8af1d5259d41bb7557efc506ee9841a228164",
|
|
13
|
+
"moduleDoc:agents/object-runtime.md": "4b6cc7930b071cf3310732f382fa06492d5eec3720fec24092ae21c207d91bfb",
|
|
14
|
+
"moduleDoc:agents/revision-guard.md": "aa6b1ddb5b6b49fa27ebbe575ec7fcd6d5ceb35ee25acc702f877a226eb9a99a",
|
|
15
|
+
"moduleDoc:agents/collection-reads.md": "4ce06e8b70b9ce9b77b47b3e2ed266c899bb7962ca015d4714f07a4aca10a865",
|
|
16
|
+
"moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5",
|
|
17
|
+
"moduleDoc:agents/data-query.md": "1b72411d441ce2285bf0c89674e7aa996832a58b552b23a6d43834b907d91355",
|
|
18
|
+
"moduleDoc:agents/schema-paths.md": "72f56201086ca687b03a44df968605b6990391df65ebfff7e461596d3f53dba6",
|
|
13
19
|
"moduleDoc:agents/change-feed.md": "c5921f2536c5f092690cc9ce5dcdf906376a34413d3c10613be6287c11f87f86",
|
|
14
20
|
"moduleDoc:agents/change-signals.md": "d9cb6a5541728ffea46607a6b1d4fa61d4621849f2b4ea86a0645fbb0af892e9",
|
|
15
21
|
"moduleDoc:agents/generators.md": "1c0243c353204b40ed4d5ad688cf812d8eb7e2bb45ffce7e3ce8dca894917b0a",
|
|
16
|
-
"moduleDoc:agents/
|
|
17
|
-
"moduleDoc:agents/
|
|
18
|
-
"moduleDoc:agents/collection-reads.md": "4ce06e8b70b9ce9b77b47b3e2ed266c899bb7962ca015d4714f07a4aca10a865",
|
|
19
|
-
"moduleDoc:agents/revision-guard.md": "aa6b1ddb5b6b49fa27ebbe575ec7fcd6d5ceb35ee25acc702f877a226eb9a99a",
|
|
20
|
-
"moduleDoc:agents/memory.md": "658cb34f3a499e290c14ba0dd8cf0bf82bcc571da01150b61070abf100894dfc",
|
|
21
|
-
"moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5"
|
|
22
|
+
"moduleDoc:agents/build-knowledge.md": "5e67560801f22ccb1413a512452bcc5d29fafed9b4411561b8c8d9095eba50f3",
|
|
23
|
+
"moduleDoc:agents/memory.md": "49334471e7a64199bb936756f3a2690ecdd9cff45f677557ea04e7c4b7c78559"
|
|
22
24
|
},
|
|
23
25
|
"exports": [
|
|
24
26
|
".",
|
|
@@ -979,27 +981,27 @@
|
|
|
979
981
|
"polymorphicAssociations": 1,
|
|
980
982
|
"uuidColumns": 3
|
|
981
983
|
},
|
|
982
|
-
"agentDoc": "# @happyvertical/smrt-core\n\nORM, code generation, AI integration, and the DispatchBus. Everything else builds on this.\n\nKey surfaces are `SmrtObject`, `SmrtCollection`, `ObjectRegistry`,\n`DispatchBus`, `GlobalInterceptors`, and `LearningMemory`; this file documents\ntheir invariants and source locations, and the module docs below cover the\nper-subsystem semantics.\n\n## Modules\n\nSubsystem semantics live in sibling module docs — read the one for the\nsubsystem you are editing. This file keeps what holds across all of them.\n\n| Module | Scope | Module doc |\n|---|---|---|\n| `src/change-feed.ts` | the adapter-agnostic change-observation spine — `_smrt_changes`, cursors, table versions, generated `_changes` routes, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` + the generated `_events` SSE route | the push companion to the change feed — the signal bus, cross-replica fan-out, the SSE route, and its documented gaps | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/` + `src/vite-plugin/web-collections.ts` | REST/CLI/MCP/web-collection generation, the `manifestHash` emission sites, and generated conditional-GET / ETag v2 semantics | [agents/generators.md](agents/generators.md) |\n| `src/schema/` | the four `SchemaGenerator` entry points, which two reach production, why schema drift stayed invisible, and the #2382 index/tenancy rules | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/data-query.ts` | canonical bounded data-query normalizer and transport-neutral envelope (#2444) | [agents/data-query.md](agents/data-query.md) |\n| `src/collection.ts` | bounded collection reads, projections, latest-related hydration, facets, counts, and read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n\n## SmrtObject Lifecycle\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [agents/revision-guard.md](agents/revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\n## LearningMemory (#1886)\n\n`LearningMemory` provides tenant-isolated, confidence-scored recall over\n`_smrt_contexts` plus optional injected semantic search. `capture()` reinforces\nsuccesses and decays failures while updating outcome counters; `recall()`\napplies confidence, expiry, time-decay, and hierarchical-scope filters and\nrefreshes `last_used_at`. Detailed persistence and search semantics are in\n[agents/memory.md](agents/memory.md). Keep semantic search behind the\n`SmrtCollection.semanticSearch`-compatible injection boundary.\n\n## SmrtCollection Query\n\n```typescript\nawait collection.list({\n where: { status: 'active', 'price >': 10 },\n limit: 50, offset: 0, orderBy: 'created_at DESC'\n});\n```\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [agents/collection-reads.md](agents/collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\nThis list is the set `@happyvertical/sql`'s `buildWhere` can execute, and\n`convertWhereKeys` accepts nothing outside it — an operator accepted here but\nunknown there fails inside the query builder, after the API said the query was\nvalid (#2276). Two entries were removed for that reason and now reject at the\nAPI boundary: `contains` (never existed in the SQL layer; use `like` with\nexplicit wildcards) and dot-notation JSON paths such as `metadata.userId` (never\nrewritten into an extraction expression, so they reached SQL as qualified column\nreferences). Re-adding either requires the query builder to support it first;\n`src/__tests__/issue-2276-where-contract.test.ts` executes every accepted\noperator against a database to keep the two in step.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [agents/query-bounds.md](agents/query-bounds.md).\n\n## Canonical Bounded Data Queries (#2444)\n\nThe normalizers and fingerprint are the trust boundary for the\ntransport-neutral query envelope; full bounds, schema, and output rules live in\n[agents/data-query.md](agents/data-query.md). Adapters own tenant/principal\naccess and query execution.\n\n## Object Memory & Semantic Search\n\nContext memory and semantic search are persistence primitives inherited by\n`SmrtObject`/`SmrtCollection`; their storage, scope, expiry, and tenant\ninvariants are in [agents/memory.md](agents/memory.md).\n\n## @smrt() Decorator Options\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes, #2357 — see \"Schema paths\"), `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## 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 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\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## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n\n## Vite Plugin\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## Schema paths (#2382)\n\n`ensureSystemTables(db, typeHint?)` is the public, idempotent provisioning\nboundary for framework-owned `_smrt_*` tables. Call it on a base PostgreSQL\nconnection before opening a caller-owned transaction; its advisory-locked\nbootstrap prevents missing-table probes from poisoning that transaction.\n\nProduction DDL comes from the **manifest** paths\n(`generateSTISchemaFromManifest`/`generateCTISchemaFromManifest`, selected in\n`src/scanner/manifest-generator.ts` → registered `schema` → `db:migrate`). The\n**registry** paths feed `getTestDatabase()`. Manifest and registry schemas must\nagree on same-package foreign keys as well as columns and indexes:\n`@foreignKey` emits a named physical constraint, while `@crossPackageRef` and\n`@tenantId` remain indexed runtime relationships without physical constraints.\nNatural-key references default to `CASCADE`; ordinary references default to\nimmediate `NO ACTION`, matching `SmrtObject.delete()`.\n\nSame-package archival/audit identifiers that intentionally outlive their\nparent may use `@foreignKey(Target, { constraint: false })`. This explicit\nexception retains relationship loading, indexing, and application-side delete\nmetadata while omitting the physical constraint, schema dependency, and\napp-side cascade/preflight action so the stored identifier survives deletion;\ndocument the retention reason at the field, and keep ordinary same-package\nrelationships constrained.\n\nWhen a relationship is valid on every engine but a particular database cannot\nfaithfully enforce its physical shape, use the public, explicit allowlist\n`@foreignKey(Target, { constraint: { engines: ['postgres', 'sqlite'] } })`.\nOnly physical DDL and schema dependency planning are engine-scoped; native UUID\nstorage, relationship loading, indexes, and application-side delete enforcement\nremain active on every engine. Empty or unknown allowlists fail closed. Do not\nuse this option to hide an otherwise invalid schema.\n\n- Change column/index emission on every shipping path, proven by the path-parity\n test `src/schema/schema-path-parity.test.ts` (#2359; index rules in the module doc). A \"same as migrations\" comment is a claim to check.\n- Every new query predicate ships with its index, or a reason it doesn't.\n- Creation is dependency-planned on every entry point. PostgreSQL defers mutual\n cycle constraints until both tables exist; SQLite keeps cycles inline;\n DuckDB refuses unsupported cycles/actions unless the field has an explicit\n physical-constraint engine allowlist rather than silently omitting them.\n In particular, generated same-package constraints retain the compatibility\n default `ON UPDATE CASCADE`; DuckDB/JSON cannot enforce that action and must\n return an actionable refusal instead of stripping the clause.\n PostgreSQL deferred adds are idempotent and probe the exact child/parent\n columns for orphans before `NOT VALID` + validation. Rollback drops children\n before parents, removes deferred PostgreSQL cycle constraints first, and\n defers SQLite checks while dropping populated cycles. Schema aggregation that\n deliberately filters a parent also removes the retained child's physical FK.\n- Numeric types, uuid casts, conflict targets, timestamps, migrations: run the\n `test:postgres` lane — SQLite affinity accepts what PostgreSQL rejects.\n- Read `dist/manifest.json`/regenerated schemas for what a decorator produced;\n count across all packages instead of sampling.\n- Tenant scoping is whole-path: every unique constraint and conflict target on a\n tenant-scoped table carries the tenant column, and every read path — not only\n `list()` — is interceptor-aware.\n- Rolling indexes out is part of the change: a bulk `CREATE INDEX` batch needs\n the bounded, concurrent migrate path (#2362, Gotchas), or it takes production\n down on deploy.\n\n## Gotchas\n\n- **Filesystem support is a lazy boundary (#1979)**: `SmrtClass` acquires `options.fs` adapters via `createFilesystemAdapter()` (`src/filesystem-loader.ts`), never a static `@happyvertical/files` import — the files SDK statically pulls @aws-sdk/client-s3 and reaches googleapis, and a static edge here would land it in every downstream SSR bundle. Node/tsx/vite-dev runtimes resolve it on first use; fully-bundled deployments import `@happyvertical/smrt-core/filesystem` at startup. Use `importOptionalDependency()` (`src/lazy-external.ts`) for any similar optional heavyweight dependency.\n- **Transaction-bound instances**: `SmrtClass.withDatabase(db, callback)` temporarily binds an initialized instance (including its public `options.db`) to a caller-owned transaction database and restores only the database binding. Transaction owners persisting one object should use `SmrtObject.withTransaction(callback)`, which restores identity/revision metadata after rollback and serializes its embedded callback with ordinary writes. Bound saves re-enter that hold. Never reach into `_db`, and do not use the same instance concurrently during either callback.\n- **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`\n- **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)\n- **No runtime schema creation**: application tables must be prepared explicitly via migrations/tooling; runtime verification is `tableExists()` only (`src/schema/table-verifier.ts`) — no column, type, or index check\n- **PostgreSQL migrate batches are always time-bounded (#2362)**: `MigrationTracker.applyAll({ atomic: true })` emits `SET LOCAL lock_timeout`/`statement_timeout` before any DDL, so a batch blocked on one table cannot hold its earlier locks indefinitely. `postgresSafe: true` adds concurrent-index mode — non-index DDL commits atomically, then index DDL runs `CONCURRENTLY` on a session pinned via `db.acquireSession()` (a pooled `db.query` would not keep the `SET` and the DDL on one connection). That mode is deliberately **not atomic**: unfinished index migrations are recorded `failed`, not `running`, and their `error_message` carries a `[smrt: concurrent-index phase 1 committed]` marker so a reconciling re-run resumes at the index build instead of replaying committed DDL. INVALID indexes are found via `pg_index.indisvalid` (`pg_indexes` reports them as present) and dropped before rebuild. Operational detail: `packages/cli/AGENTS.md`.\n- **Retry logic is transient-only (#2366)**: `db.get()`/`db.upsert()` retry 4× total (initial + 3), but `ErrorUtils.withRetry` classifies via the cause chain (`src/db-errors.ts`) and rethrows deterministic failures immediately — constraint violations, bad input syntax, missing tables, aborted PG tx (`25P02`). `@happyvertical/sql` stringifies the driver text into `context.originalError`, so **never match `error.message`**; use `classifyDatabaseError()` / `isUniqueViolationError()` / `isAbortedTransactionError()`.\n- **Field caching**: `_cachedFields` populated during `Collection.create()` — eliminates async `getFields()` per query\n- **Smart cloning**: arrays/objects shallow-cloned in property init to prevent aliasing (Issue #22)\n- **Table verification cache**: `isTableVerified(dbUrl, tableName)` avoids redundant `tableExists()` calls\n- **Manifest required**: build-time AST scanning creates manifest. Without vitest plugin → \"No field metadata\"\n- **ManifestBuilder fails on scanner errors**: every production manifest path\n must abort before adapting partial scan results. A syntax error or unresolved\n `@smrt()` config spread cannot be allowed to emit a default-open manifest.\n- **Vite plugin loads scanner from `dist/` first**: `src/vite-plugin/import-build-aware.ts` prefers `dist/` when it exists on disk; it only falls back to `src/` on fresh clones. So if you edit `src/scanner/*.ts` or `src/schema/generator.ts` and want those edits reflected in consumer manifest generation, you must rebuild (`pnpm build` or have `pnpm dev` / `pnpm build:watch` running in core). This is intentional — sniffing `.ts` vs `.js` via `import.meta.url` was non-deterministic under tsx and broke 12–13 publishes (#1139).\n- **Bundled registry ownership**: flattened production bundles can rewrite constructor names and make decorator-time stack inference attribute provider code to the consumer. Generated registration repairs identity only from the exact imported constructor plus an explicit package and isolated one-object manifest; never infer ownership from output paths, simple names, or table names. Distinct packages may export the same simple name under qualified keys. The production-consumer gate lives in `packages/bundle-gate/src/__tests__/registry-identity.spec.ts` (#2308).\n",
|
|
984
|
+
"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/change-feed.ts` | Durable changes, cursors, table versions, retention | [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/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\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- 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- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses an advisory lock. 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\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\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",
|
|
983
985
|
"moduleDocs": [
|
|
984
986
|
{
|
|
985
|
-
"path": "agents/
|
|
986
|
-
"module": "
|
|
987
|
-
"content": "#
|
|
987
|
+
"path": "agents/object-runtime.md",
|
|
988
|
+
"module": "object-runtime",
|
|
989
|
+
"content": "# Object and collection runtime\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [revision-guard.md](revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\n\n## SmrtCollection Query\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [collection-reads.md](collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\n`convertWhereKeys` must accept only operators executable by the SQL builder.\n`contains` and dot-notation JSON paths reject at the boundary; use `like` with\nexplicit wildcards. Adding operators requires SQL support first.\n`src/__tests__/issue-2276-where-contract.test.ts` executes the accepted set.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [query-bounds.md](query-bounds.md).\n\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n"
|
|
988
990
|
},
|
|
989
991
|
{
|
|
990
|
-
"path": "agents/
|
|
991
|
-
"module": "
|
|
992
|
-
"content": "#
|
|
992
|
+
"path": "agents/revision-guard.md",
|
|
993
|
+
"module": "revision-guard",
|
|
994
|
+
"content": "# Revision compare-and-swap guard (`src/revision-guard.ts`)\n\nEvery persisted `save()` pins its `UPDATE` to the revision the writer loaded;\n`claimRevision()` does the same without running domain hooks, and\n`delete({ expectedUpdatedAt })` binds the same predicate into its final\n`DELETE`. Zero affected rows raises `RUNTIME_REVISION_CONFLICT` rather than\noverwriting or removing a newer row.\n\n## Why the predicate is not an equality (#2620)\n\nThe guard used to compare `updated_at` to `loadedRevision.toISOString()`. On\nPostgreSQL a JavaScript `Date` is two lossy conversions away from the stored\nvalue, so that predicate matched no row at all in two common situations — and\nthe object then conflicted on *every* later save, permanently, rather than\nlosing a race:\n\n- **Precision.** `updated_at` is a microsecond column. Any row last written by\n raw SQL — `updated_at = CURRENT_TIMESTAMP` / `now()`, including SMRT's own\n migration backfills — carries a sub-millisecond tail a `Date` cannot hold.\n- **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping still\n hold `updated_at` as `timestamp WITHOUT time zone`, and `pg` hydrates that\n type in the process zone, so on a non-UTC host `toISOString()` renders a wall\n clock the row never held. The same columns are written under three different\n conventions — `pg` serializes a bound `Date` in the process zone,\n `claimRevision()` writes a UTC ISO string, and raw `CURRENT_TIMESTAMP` writes\n in the *server* zone — so no single rendering can match every row.\n\n## What the predicate does instead\n\n`postgresRevisionCondition()` builds\n`date_trunc('milliseconds', updated_at) IN (…)` over both wall-clock renderings\nof the revision, the process-zone one and the UTC one, each tagged `+00` so a\n`timestamptz` comparison honours it and a `timestamp` comparison discards it —\nthe predicate therefore does not depend on the *session* TimeZone either. On a\nUTC process the two renderings coincide and the condition is single-valued.\n\nLost-race semantics are preserved: a concurrent writer advances `updated_at` to\nroughly \"now\", so it must land on the loaded revision — or, on a non-UTC\nprocess only, on that revision shifted by the whole UTC offset — to the\nmillisecond before it could slip past. Collapsing that second rendering so the\npredicate is single-valued on every process is tracked as #2623.\n\n## Rules\n\n- Never rebuild this predicate by hand; call `postgresRevisionCondition()`.\n Every guarded write — `save()`, `claimRevision()`, and the guarded `DELETE` —\n goes through `SmrtObject.revisionPredicate()` so no path is left on the exact\n equality.\n- The condition is PostgreSQL-only. Embedded engines take the process-local\n compare/upsert fallback (`usesEmbeddedRevisionFallback`), and remote LibSQL\n stores ISO text whose exact equality round-trips losslessly.\n- Custom write paths must go through `save()`, `save({ expectedUpdatedAt })`,\n or `claimRevision()` rather than bypassing the CAS ordering contract.\n- The driver-layer half — `pg` hydrating and serializing `timestamp` columns in\n the process zone — is tracked as happyvertical/sdk#1223. The guard\n deliberately assumes neither hydration convention, so a UTC-hydration fix\n there cannot break it.\n\n## Coverage\n\n`src/__tests__/issue-2620-revision-guard-precision-postgres.optional.test.ts`\nruns the whole battery — guarded save, `save({ expectedUpdatedAt })`,\n`claimRevision()`, guarded delete, and their still-conflicts counterparts —\nagainst both `updated_at` column shapes in the registered PostgreSQL suite (`pnpm --filter @happyvertical/smrt-core\ntest:postgres`). `src/__tests__/revision-guard.test.ts` covers the rendering\nitself in the default suite.\n"
|
|
993
995
|
},
|
|
994
996
|
{
|
|
995
|
-
"path": "agents/
|
|
996
|
-
"module": "
|
|
997
|
-
"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| CLI | `src/generators/cli.ts` | `objectname:action` admin commands — writable allowlist, exhaustive-include, `--from-file`, fail-closed tenant context |\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).\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` is a warning. A cross-file duplicate\narrives as a *diagnostic* rather than two entries — the scanner's merge already\ndropped the loser — so that diagnostic maps to the duplicate error rather than\nthe not-static warning; otherwise the error would be unreachable for the case it\nexists to catch.\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"
|
|
997
|
+
"path": "agents/collection-reads.md",
|
|
998
|
+
"module": "collection-reads",
|
|
999
|
+
"content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Collection reads\n\nThis module covers bounded collection reads beyond the basic query contract in\n`packages/core/AGENTS.md`.\n\n## Projections and related rows\n\n`list({ select })` uses SMRT field names, maps them to database columns, and\nreturns plain rows without hydrating objects. It composes with `where`,\n`orderBy`, `limit`, and `offset`, runs normal `beforeList`/tenant interceptors,\nand is limited to column-backed fields; it cannot combine with `include`.\n\n## Bounded STI discriminator scopes\n\nAn STI child collection remains scoped to its own qualified `_meta_type` by\ndefault. A migration that must read registered sibling types may opt into an\nexplicit allowlist:\n\n```typescript\nawait impressionEvents.list({\n stiScope: {\n types: [\n '@anytown/advertising:AdImpression',\n '@anytown/advertising:LegacyAdImpression',\n ],\n },\n orderBy: 'created_at ASC',\n limit: 100,\n});\n```\n\n`stiScope.types` accepts 1–50 unique, qualified, registered types, all sharing\nthe child collection's STI root. Empty, simple-name, unknown, duplicate,\nunrelated, and non-child scopes fail at the collection boundary. The option is\nsupported by `list()`, `count()`, `counts()`, `facets()`, and\n`listWithLatestRelated()`. These methods retain their normal field validation,\nprojection or polymorphic hydration, pagination and cache-key construction;\nnormal read and tenant interceptors still run and are ANDed with the allowlist.\nPoint reads through `get()` remain child-only; use a bounded\n`list({ where, limit: 1, stiScope })` migration read when sibling hydration is\nrequired.\n\nFor one child per parent, use\n[`latest-related.md`](latest-related.md). It uses a portable ranked CTE,\ndeclared primary keys, adapter-specific offset-only syntax, explicit aliases,\nand hydrates only the visible parent page.\n\n## Facets, counts, and read plans\n\n`collection.facets({ fields, where })` runs one bounded `GROUP BY` per requested\nfield and returns `{ field, values: [{ value, count }] }`. It accepts at most 20\nfields, clamps value limits to 1,000 and the collection ceiling, never hydrates\nobjects, and applies the same read/tenant/sensitive-field rails as `select`.\nStored array/string-list values are grouped as stored; they are not unnested.\n`collection.counts({ where })` returns `{ total, filtered }` through two scoped\n`COUNT(*)` queries. Local coverage is SQLite/DuckDB; optional scalar PostgreSQL\ncoverage requires `SMRT_TEST_POSTGRES_URL`.\n\n`executeCollectionReadPlan()` bounds concurrent reads across independent\ncollections while preserving the normal registry and collection options. The\ncaller supplies a positive `maxConcurrency`; the executor does not compose SQL,\ncache, or alter pool defaults, and drains already-started work before returning\nthe first error.\n\n`where` operators must remain aligned with `@happyvertical/sql`'s `buildWhere`:\n`=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, and `like`. Arrays imply `IN`,\nand null values render `IS NULL`/`IS NOT NULL`. `contains` and dot-notation JSON\npaths are intentionally rejected until the SQL layer supports them.\n"
|
|
998
1000
|
},
|
|
999
1001
|
{
|
|
1000
|
-
"path": "agents/
|
|
1001
|
-
"module": "
|
|
1002
|
-
"content": "# smrt-core/schema paths\n\nModule semantics for `src/schema/` — which `SchemaGenerator` entry point reaches\na real database, what each one emits, and the rules that keep them in step.\nPackage orientation, the cross-module invariants, and the traps that apply\nbefore editing anything live in [../AGENTS.md](../AGENTS.md) — read that first;\nits \"Schema paths\" section is the short form of everything below.\n\nWritten from the 2026-08-17 database-layer gap assessment (epic #2382). Symbol\nnames here are stable; the line numbers the assessment quotes are not, so trust\nthis call graph and re-grep before citing a location.\n\n## Four entry points, two of which ship\n\n`src/schema/generator.ts` exposes four index-emitting entry points. They do not\nproduce the same schema for the same class.\n\n| Entry point | Selected by | Status |\n|---|---|---|\n| `generateSTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateCTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |\n| `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |\n\nA fifth entry point, the build-time AST `generateSchema(objectDef)`, existed\nuntil #2380: it fed only the `smrt:schema` virtual module, which had no\nconsumer, had rotted relative to the four paths above (an `idx_`-prefixed\nnaming scheme none of the others use, and no conflict-index emission at all),\nand was deleted rather than wired up. `SchemaOverrideSystem`\n(`schema/override-system.ts`) — unwired, and its two non-generic methods\nhard-coded a schema extension for a project outside this monorepo — was\ndeleted alongside it. See rule 9 and the new rule at the end of this file.\n\nProduction DDL takes the manifest route:\n\n```\n@smrt() class ─▶ scanner ─▶ manifest.json ─▶ generate{STI,CTI}SchemaFromManifest\n ─▶ registered `schema` ─▶ ObjectRegistry.getAllSchemasAsDefinitions()\n ├─▶ smrt db:migrate | db:diff | db:status\n │ (the CLI drives SchemaComparer + MigrationTracker directly)\n └─▶ migrateSmrtSchemas() / getPendingSchemaStatements()\n (src/migrations/orchestrate.ts — exported for programmatic\n use; no in-repo caller outside its own tests)\n```\n\nThe suite takes the registry route. Before #2359 the registry route emitted\nindexes the manifest route did not — per-column foreign-key indexes, and STI\npartial FK indexes filtered by `_meta_type` — so tests ran against a richer\nschema than any deployment received, the manifest STI path populated a\n`fkColumnsByClass` map it never read, and the manifest CTI path had no FK loop\nat all. `src/testing/database.ts`'s \"same as migrations\" comment described an\nintent, not the code.\n\nSince #2359 the two families share one set of index helpers and\n`src/schema/schema-path-parity.test.ts` runs the same fixture manifest through\nthe manifest paths, through `ObjectRegistry.registerFromManifest()` + the\nregistry paths, and through `getAllSchemasAsDefinitions()`, asserting identical\ncolumn and index sets. Extend that fixture with every generator change; a\ndivergence is a bug in the generator, not an exception to add to the test.\n\n### Index rules (#2359)\n\n- **Reference columns are always indexed.** `ensureReferenceColumnIndexes()`\n runs last on every path and gives each `@foreignKey`, `@crossPackageRef` and\n tenant column `<table>_<column>_idx` unless an UNQUALIFIED index (no `WHERE`,\n no JSON path) already leads with it — the `conflictColumns` unique index or an\n `indexed: true` opt-in, or the column's own inline UNIQUE. A partial\n `WHERE _meta_type = …` index does not count: base-class polymorphic queries\n carry no discriminator predicate. `indexed: true` on a reference column is\n redundant. Roll the index wave out to production with\n `smrt db:migrate --postgres-safe` (concurrent-index mode, #2362): a plain\n atomic batch takes SHARE/ACCESS EXCLUSIVE locks for ~230 index builds. STI FK indexes are plain, one per\n column, not per-class partial.\n- **No index on the primary key.** `<table>_id_idx` is gone from every path,\n and `conflictColumns` equal to the PK column set emit no conflict index\n (`ON CONFLICT (id)` binds to the PK constraint). `SchemaComparer` drops the\n legacy non-unique single-column PK index from existing databases without\n `--drop-indexes` when the live table reports that column as its sole primary\n key (never a UNIQUE one — on PostgreSQL that may back a custom-named PRIMARY\n KEY constraint, and `DROP INDEX` on it would fail the atomic batch).\n- **Slug loading keeps its index.** Custom `conflictColumns` replace the\n `(slug, context)` unique index; `loadFromSlug()`/`getId()`/`getSavedId()`\n still filter on slug/context, so a plain `<table>_slug_context_idx` is kept\n (additive; routing those lookups through the conflict key would change which\n row a slug resolves to). The tenant-led default key below counts as serving\n it (`servesSlugLookup()`): a tenant-scoped slug lookup carries the tenant\n predicate (#2365) and is served by the prefix, so no second index.\n- **Tenant-scoped tables key per tenant (#2360).** A tenant-scoped class with\n no explicit `conflictColumns` upserts on, and indexes,\n `(tenant_id, slug, context)` — `(tenant_id, slug, context, _meta_type)` for\n an STI hierarchy — resolved by one rule on both paths:\n `ManifestGenerator.normalizeConflictColumns()` materializes it into\n `decoratorConfig.conflictColumns` for the manifest paths (so the manifest,\n the schema, `smrt-knowledge.json` and the runtime read one value), and\n `ObjectRegistry.getConflictColumns()` derives the same value at runtime from\n the schema owner's `tenantScoped` config (`ObjectRegistry.getTenantColumn()`;\n an STI child resolves through its root; a `@report` class through its\n group/bucket columns; a custom primary key through that key). Explicit\n `conflictColumns` are never rewritten. `src/schema/conflict-target.ts` holds\n the shared helpers. Consequences: the index NAME stays\n `<table>_slug_context_idx` / `_slug_context_meta_type_idx`, so the differ\n swaps the columns of an existing global unique in place by name (a superset\n key — creating it cannot fail on existing rows); the tenant-led key also\n serves the tenant column, so `<table>_tenant_id_idx` is no longer emitted\n for those tables (an existing one is an orphan the differ drops only with\n `--drop-indexes`); NULL-tenant rows (`mode: 'optional'` outside a tenant\n context) dedup among themselves through the SDK's null-aware upsert\n (`IS NOT DISTINCT FROM` under a PostgreSQL advisory lock / an in-process\n lock on SQLite) — application-enforced now, where the old global index was\n database-enforced: the tenant-led index treats NULLs as distinct, so raw SQL\n can insert two global rows with one slug, and a raw\n `ON CONFLICT (slug, context…)` against such a table no longer binds (use\n `WHERE NOT EXISTS`, plus an advisory lock on PostgreSQL). Emitting\n `NULLS NOT DISTINCT` on PostgreSQL ≥ 15 (the SDK already detects it) would\n restore the database arbiter — a follow-up. The `save()` path serializes an\n unset tenant field as an explicit `NULL` whatever its registered type,\n because the SDK rejects an upsert whose conflict column is missing from the\n row.\n- **Rolling the tenant-led key out (#2360).** There is no mixed-version state:\n new code against the old index fails every NEW-object create on a\n tenant-scoped default-key table (PostgreSQL 42P10, SQLite \"ON CONFLICT\n clause does not match…\"), and old code against the new index fails the same\n way, because the conflict target must match the unique index's column set\n exactly; only persisted objects (upsert on `id`) keep saving. Deploy the code\n and run `smrt db:migrate` in the same maintenance step. The plan is one\n `DROP INDEX` + `CREATE UNIQUE INDEX` per table under the SAME name (a\n superset key, so the build cannot fail when the old same-name index was a\n valid UNIQUE over the subset key; a #1165-class table whose old index was\n non-unique or missing may hold duplicates that a superset UNIQUE rejects —\n `db:diff` shows which tables' old index is non-unique or missing; dedupe\n those rows before migrating). Atomic mode swaps every table in one\n transaction: `DROP INDEX` takes ACCESS EXCLUSIVE and holds it until commit,\n which blocks ALL access to those tables — reads included — for the batch;\n size `statementTimeout` for the largest tenant-scoped table. That is the\n maintenance window this rollout requires anyway (no mixed-version state), so\n run this wave — the #2359 index wave included — in atomic mode inside it;\n the \"roll out with `--postgres-safe`\" advice above applies to a #2359-only\n wave, because `--postgres-safe` runs the two statements sequentially per\n table, so each table has NO conflict index between them and a failed rebuild\n leaves it without one until the re-run. The recreate has no automatic\n DOWN: reverting the code means re-creating the old index by hand. And\n legacy NULL-tenant rows fork rather than get adopted — a tenant-context save\n whose slug matches a `(NULL, slug, ctx)` row now inserts `(tenant, slug,\n ctx)` beside it, and that tenant no longer sees the legacy row — so backfill\n `tenant_id` (anytown: `SET tenant_id = context::uuid`) BEFORE this release.\n Ingestion that relied on natural-key dedup across tenants now inserts one\n row per tenant (release note).\n- **STI `@field({ unique: true })` is enforced through indexes** (the differ can\n add an index to an existing table, never a column constraint): a full\n `<table>_<col>_unique_idx` when the STI base declares it, one\n `<table>_<col>_<class>_unique_idx WHERE _meta_type = '<qualified>'` per class\n when only descendants do — uniqueness per concrete class, not across the\n subtree. DuckDB/JSON have no partial indexes, so the descendant-scoped shape\n (`isStiSubtypeUniqueIndex`) is not emitted there — degrading it to a full\n UNIQUE would constrain every subtype; the DDL strategy and the differ both\n skip it, while other partial indexes keep degrading to full ones as before. Remember the\n framework serializes an unset text field as `''`, so a unique optional text\n field must be `nullable: true` with a `null` initializer or every unset row\n collides.\n- **Every class in an STI hierarchy carries the schema of the one shared\n table**, generated from the root base (`ManifestGenerator.generateSchemas()`\n resolves the root through `findSTIBaseInfo`), so a child never treats its own\n descendant-only unique field as base-declared.\n\n`src/schema/utils.ts` sits in between, and the two exports differ:\n\n- `generateSchema()` (reached from `SmrtCollection.generateSchema()`) always\n rebuilds from the registry and writes the result back into the registry,\n replacing whatever the manifest registered for that class.\n- `ensureSchema()` (reached from the deprecated `smrt db:setup`) is\n manifest-first: it takes `ObjectRegistry.getSchema()` plus the merged\n `getAllSchemasAsDefinitions()` table definition, and only falls back to\n `generateSchema()` when no schema is registered at all.\n\nSo a normal build keeps the manifest schema through `db:setup`, and a\nregistry-derived schema is a dev/test artifact. `smrt-content` shows what one\nlooks like: `packages/content/src/hooks.server.ts` `bootstrapSchema()` calls\n`generateSchema()` for every registered class and then `ensureSchema()` from the\nSvelteKit `handle` hook on any `/api/*` request, so that process holds\nregistry-derived schemas rather than the manifest ones. It reaches only that\npackage's own `vite dev` app — the library build excludes the file and the\npackage never exports it — but it is the shape to recognize. Check which route a\nprocess actually took before trusting a reproduction.\n\n## Why the drift stayed invisible\n\nEvery drift oracle compares a database with the same artifact that dropped the\nindex:\n\n- `verifyPersistenceTable()` (`src/schema/table-verifier.ts`) calls\n `db.tableExists()` and nothing else. \"Runtime verifies schema\" has always meant\n existence-only — no column, type, constraint, or index comparison.\n- `smrt doctor` never opens a database connection.\n- `db:status` and `db:diff` diff the live database against\n `getAllSchemasAsDefinitions()`, i.e. the manifest projection.\n\nAn index the manifest never emitted is \"in sync\" by construction. That is how a\nproduction database reached 164 unindexed `tenant_id` columns while `db:status`\nreported no drift (#2356 → #2359). The assessment's other counts — 196/231\n`@foreignKey` and 91/92 `@crossPackageRef` columns with no production index,\n238/238 tables carrying a redundant index on the primary key, zero DB-level\nforeign-key constraints on any engine — come from regenerating every package's\nschema against a live database, so re-measure rather than quote them once the\nepic's fixes land.\n\n## Rules\n\n### 1. Verify against the production path, not the test path\n\nAny change to column or index emission goes on **all** paths that ship and is\nproven by the path-parity test (`src/schema/schema-path-parity.test.ts`, #2359)\n— extend its fixture; a green suite otherwise proves the registry paths only.\nRead the call graph before believing a comment: \"same as migrations\" was wrong\nfor years.\n\n### 2. Every new query predicate ships with its index\n\nCollection methods, poll loops, auth lookups, junction right-side filters, and\npolymorphic owner lookups all count — or write down why the predicate does not\nneed one. For list workloads, EXPLAIN on a PostgreSQL snapshot; the measured\nspread on the assessed workload was 21 ms → 0.1 ms.\n\n### 3. Run the PostgreSQL lane\n\nAnything touching numeric types, uuid casts, upsert conflict targets, timestamps,\nor migrations runs the package's `test:postgres` script:\n\n```bash\npnpm --filter @happyvertical/smrt-<pkg> test:postgres\n```\n\ncore, cli, users, sales, marketing, analytics, and vitest carry the lane.\nSQLite's type affinity accepts values PostgreSQL rejects — a money field declared\n`number = 0` compiles to INTEGER and only fails on PG (#2361).\n\n### 4. Read the built artifact, not the source\n\nWhat a decorator produced is in `dist/manifest.json` and in regenerated schemas:\n`integer` vs `decimal`, the actual index list, the actual conflict columns. When\nthe question is \"how many tables/columns/indexes\", regenerate and count across\nevery package; do not sample a few and extrapolate.\n\n### 5. Index intent belongs on both the constraint and the read path\n\nA conflict target is not automatically a unique index, and a unique index is not\nautomatically the index a read path uses. Custom `conflictColumns` used to\nreplace the `(slug, context)` index while `loadFromSlug`/`getId` still queried\nslug+context, and STI dropped `@field({ unique: true })` — both fixed in #2359,\nsee \"Index rules\" above. Check the pair, not the declaration.\n\n### 6. Multi-tenancy is a whole-path property\n\nEvery unique constraint and every conflict target on a tenant-scoped table\nincludes the tenant column — otherwise a second tenant's `save()` of the same\nnatural key updates the first tenant's row through `DO UPDATE SET` (#2360; the\ndefault key now does, see \"Index rules\" — an explicit `conflictColumns` that\nomits the tenant column is the class author's own key and is not rewritten).\nAnd every read path is interceptor-aware: hydration\n(`loadFromId`/`loadFromSlug`), get-by-slug, vector search, and collection\nmemory, not only `list()` (#2365).\n\n### 7. Retry only transient errors\n\nClassify through the cause chain (SQLSTATE), never on a message substring, and\nnever retry inside an aborted PostgreSQL transaction (`25P02`). Test the\ncontract end to end against a real database, not only the classifier (#2366).\n\n### 8. Thread new decorator options through every config-rebuild site\n\nA new `@smrt()` or `@field()` option that affects schema must reach the\n`SchemaGeneratorConfig` type in `src/schema/generator.ts` and every site that\nrebuilds that config — `src/schema/utils.ts` and `src/testing/database.ts` — or\nit is silently dropped on the paths that rebuild it (#2357).\n\n### 9. Delete or wire dead paths, and write docs to what the code does\n\nDead code that looks canonical misleads the next agent: the AST `generateSchema`\npath, `SchemaOverrideSystem`, and the never-emitted `triggers: []` all read as\nsupported surfaces (#2380). Documentation follows the implementation, not the\nintent — say \"verifies the table exists\" when that is what runs.\n\n### 10. Untracked \"known limitation\" comments are bugs nobody will read\n\nFile the issue and link it from the comment. A `products` comment explaining why\na conflict-column change was refrained from sat there for months — and\nmisdescribed the failure mode the whole time.\n\n### 11. Consumer repair scripts are signals\n\nDownstream repair tooling (anytown's `db-repair-plan.ts` carried column-type\nrepairs, missing STI columns and indexes, and `tenant_id` backfills since April)\nis the consumer-side record of framework gaps. Mine it during triage.\n\n### 12. Try to falsify before filing, and treat operations as correctness\n\nRe-verify a finding at source before it becomes an issue — one assessment\ncandidate claimed conflict indexes past two columns were narrowed to two\ncolumns, when only the index *name* is shortened. And an index fix that ships\nwithout a bounded-timeout, `CONCURRENTLY`-capable migrate path can take\nproduction down on rollout (#2362).\n\n### 13. Composite indexes are declared, not inferred (#2357)\n\nThe generated set only covers foreign keys, unique/conflict columns, the STI\ndiscriminator, reference columns (#2359), the default list ordering (rule 18\nbelow), and single columns opted in with `@field({ indexed: true })`. A list\nworkload's access path is composite, so declare it:\n\n```ts\n@smrt({\n indexes: [\n { name: 'contents_tenant_id_publish_date_idx',\n columns: ['tenantId', 'publish_date'] },\n ],\n})\n```\n\n`columns` takes field names or column names in access-path order — filter\ncolumns first, sort column last. Declare columns, not a direction: PostgreSQL\nscans a btree either way, so an ascending index also serves the matching\n`ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`\n(partial index) are honoured.\n\n`appendDeclaredIndexes()` runs first on all four entry points, ahead of\n`ensureDefaultListOrderingIndex()` (rule 18) and `ensureReferenceColumnIndexes()`,\nso a declared composite leading with the tenant column (or any reference column)\nreplaces the automatic standalone index rather than duplicating it.\nUnknown columns, malformed entries, and a name collision with a different index\nall fail generation — a silently dropped index only surfaces later as a\nproduction slowdown. Rule 8 above is why this works at runtime at all.\n\n### 14. Relationship targets resolve to a class name on both paths\n\n`@foreignKey`/`@oneToMany`/`@manyToMany` accept a class, a name string, or a\n`() => Target` thunk. The decorator invokes the thunk and throws when the target\ncannot be resolved (never `related: ''`); the scanner unwraps the same thunk\nfrom raw source (never `related: '() => Target'`). An unresolved target silently\ncosts the relationship edge, `loadRelated()`, and the FK-derived index (#2379).\nA thunk resolves at decoration time, so a target declared later in the same\nmodule is still in its temporal dead zone — use the string form there.\n\n### 15. A SQLite type change is a table rebuild (#2370)\n\nSQLite has no `ALTER TABLE ... ALTER COLUMN ... TYPE`, so\n`src/migrations/sqlite-rebuild.ts` answers a `type_upgrade` on SQLite with the\nstatement list SQLite's own docs prescribe: stage a new table under\n`_smrt_rebuild_<table>`, copy, drop, rename, replay the indexes and triggers.\n`SchemaComparer.compareTable` swaps that plan in for the differ's\n\"requires table recreation\" placeholder, so `db:migrate` applies it inside the\nnormal atomic batch instead of exiting 1 forever.\n\nFour properties of that module are load-bearing; keep them if you touch it:\n\n- **The target shape comes from the live `sqlite_master` DDL**, retyping only\n the drifted columns. It is not regenerated from the manifest, so the rebuild\n never becomes an implicit `DROP COLUMN`, and it preserves table constraints,\n `CHECK`s, and `WITHOUT ROWID`/`STRICT`.\n- **The rebuild is hoisted ahead of the table's other column changes.** Its\n staging DDL and copy list are captured at diff time, and the differ emits\n changes in manifest field order, so a new field declared above the retyped\n one would otherwise run `ALTER TABLE ... ADD COLUMN` first and have the\n rebuild silently drop it — both statements succeed and the batch commits.\n Rebuild first, then add columns to the rebuilt table.\n- **The copy carries no `CAST`.** SQLite applies the destination column's\n affinity on insert — the same conversion a fresh table performs. An explicit\n cast is worse: non-numeric TEXT cast to REAL/INTEGER silently becomes `0`,\n and an ISO timestamp cast to NUMERIC-affinity `DATETIME` becomes its year.\n- **It refuses when any table has a foreign key onto the target and\n `PRAGMA foreign_keys` is ON** (the SMRT adapter's default). `DROP TABLE`\n performs an implicit `DELETE FROM` that fires `ON DELETE CASCADE` on\n children, and `defer_foreign_keys` defers constraint *checks*, not FK\n *actions* — verified: the child rows go. The target's own self-reference\n counts, because the staging table copies that clause and becomes a child of\n the table being dropped (verified: a two-row self-referencing table finishes\n the rebuild holding one row). Such a column stays manual drift.\n- **`PRAGMA legacy_alter_table` brackets the rename**, because SQLite ≥ 3.25\n re-parses the schema on `ALTER TABLE ... RENAME` and a view still pointing at\n the just-dropped table makes it fail outright. It is restored immediately\n after; a rolled-back batch leaves it set on that connection, which is inert\n here only because nothing else in SMRT renames a table.\n\nAll the drifted columns of one table share a single rebuild: the first change\ncarries the plan and the rest become `no change needed` comments that the CLI\nclassifies as no-ops.\n\n## What the differ compares (#2369)\n\n`SchemaComparer` (`src/migrations/differ.ts`) compares each manifest column's\ntype, then — unless the type itself is drifting — its nullability and default,\nand always reports what it will not touch:\n\n- **Strengthening** (`SET NOT NULL`, `SET DEFAULT`) is executable on\n PostgreSQL/DuckDB. `SET NOT NULL` is preceded by an `UPDATE … WHERE c IS NULL`\n backfill of the manifest default; without a default the live data is probed\n and, if NULLs exist, the change is reported (comment SQL + `advisory`) instead\n of emitting an ALTER that would abort the atomic batch.\n- **Relaxing** (`DROP NOT NULL`, `DROP DEFAULT`) is a report-only advisory until\n the caller passes `relaxColumns` (`db:migrate --relax-columns`). The manifest\n can be under-specified (#2372 registration-order weakness), so a live column\n that is stricter than the manifest is never weakened silently.\n- **Orphans** — DB columns absent from the manifest, DB tables no manifest\n declares (`SchemaDiff.orphan_tables`), and unclaimed `*_key` unique constraint\n indexes — are always reported. A NOT NULL orphan without a default is a\n `warning` advisory (every ORM insert fails on it); `includeDroppedColumns`\n (`--drop-columns`) drops it, `relaxColumns` relaxes it. Advisory-only changes\n carry no SQL, never reach the tracker, and do not fail `db:migrate`.\n- **ADD COLUMN** is planned per engine: DuckDB rejects every inline constraint\n (add with `DEFAULT`, then `SET NOT NULL`, `CREATE UNIQUE INDEX`); SQLite\n rejects inline `UNIQUE` (separate `CREATE UNIQUE INDEX <table>_<col>_key`, the\n PostgreSQL constraint-index name, so the orphan sweep leaves it alone) and\n `NOT NULL` without a default on a populated table; PostgreSQL keeps constraints\n inline. DuckDB has no `ADD CONSTRAINT`, so the separate index is the only\n way to add uniqueness there; the bundled DuckDB 1.4.x resolves\n `ON CONFLICT (col)` through that index (the old #12684 limitation the DuckDB\n strategy's `requiresInlineUnique()` note describes no longer reproduces —\n the #2369 DuckDB test pins the upsert), older DuckDB builds may not. A required column with no default is enforced only on an empty table;\n on a populated one it is added nullable and the `NOT NULL` is reported as a\n manual follow-up on every engine.\n- **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual\n (comment SQL → `db:migrate` exit 1). The #2370 rebuild (rule 15) consumes\n only `type_upgrade` placeholders today; extending it to rewrite constraints\n would lift this.\n- Defaults compare through `canonicalizeDefault()`, which folds engine\n renderings (`'x'::text`, `CAST('t' AS BOOLEAN)`, `CURRENT_TIMESTAMP` vs\n `now()`) by manifest type; an unclassifiable rendering skips the comparison\n rather than risking a false positive that would churn every run. The\n round-trip test (create from each DDL strategy → compare → zero changes) in\n `src/migrations/__tests__/issue-2369-*.test.ts` guards this.\n\n### 16. `schema.ddl` is a preview, not the table\n\n`SchemaDefinition.ddl` / `manifest.json` `schema.ddl` is the engine-neutral\nCREATE TABLE string from `SchemaGenerator.generateSQL()` with no engine: no\nindexes, no triggers, abstract `REAL`/`JSON`/`UUID`/`TIMESTAMP`. It is kept for\nbackward compatibility only. Everything that needs an executable table renders\n`columns` + `indexes` through `getDDLStrategy(engine)` — `db:migrate`\n(`migrations/orchestrate.ts`), `MigrationGenerator` (default\n`materializeStructuredSchema: true`; `false` is a deprecated opt-out),\n`SchemaAggregator`, and `createIsolatedTestDbFromManifest` in smrt-vitest, the\nlast two via `src/schema/manifest-schema.ts` (`collectManifestTables` /\n`renderCollectedManifestTable`). The cached string is merged in only for a\ntable whose contributors expose no structured columns (hand-authored\nmanifests); table constraints that exist only in the string are dropped with a\nwarning, as `db:migrate` drops them. Do not add a new consumer of the\nstring, and do not write a private CREATE INDEX renderer — the retired ones\ndropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out\n`PRIMARY KEY NOT NULL`: SQLite lets a bare non-INTEGER PRIMARY KEY hold NULL.\n\n### 17. The merged table shape is registration-order independent (#2372)\n\n`getAllSchemas()` and `getAllSchemasAsDefinitions()` fold every class that\nshares a physical table — the whole STI hierarchy — into one shape. Both route\nthrough `buildMergedTableSchemas()`, which groups contributors by table and\nthen merges them in a **deterministic** order: the STI base first, then\nancestors before descendants, then by qualified name.\n\nThat order matters because the first contributor seeds the table: it supplies\nthe fallback base columns, the `idType`, the conflict columns and the cached\nDDL, and its columns win every merge conflict. When registration order decided\nit, an STI child that carries no manifest `schema` — the external- and\nconsumer-manifest case — seeded the table from bare fallback columns and the\nbase class's richer ones were skipped when it registered later, yielding\n`context TEXT` instead of `context TEXT NOT NULL DEFAULT ''` and timestamps\nwith no NOT NULL/DEFAULT. The shipped content manifest lists `Article` before\n`Content`, so the losing order was the one that shipped, and the differ\ncompares types only, so the weak fresh-create was never repaired.\n\nTwo invariants keep the two assembly paths agreeing:\n\n- `createBaseColumns()` mirrors what `generateSchemaFromManifest` /\n `generateSTISchemaFromManifest` emit for the same table, so a table built\n from runtime field metadata alone has the same NOT NULL/DEFAULT shape as one\n built from a manifest. Note `_meta_type` is `TEXT NOT NULL` with **no**\n default, matching the generator.\n- `fieldsToColumns()` reads `required`, `default`, and `description` from the\n top level *or* `_meta`. Registry fields normalize them into `_meta`\n (`manifest-field-merge.ts`), so reading only the top level silently dropped\n NOT NULL and DEFAULT for every registry-sourced field.\n\nSTI columns stay nullable regardless of the field's `required` flag\n(`fieldsToColumns(fields, { stiUnionColumns: true })`): the table holds the\nunion of all subtypes' fields, so a column only one subtype declares is never\npopulated on a sibling's row. Declared defaults are still emitted. This matches\n`generateSTISchemaFromManifest`, which sets `notNull: false` on every non-system\nSTI column.\n\nWhen adding a class-level input to the merged shape, take it from the seeding\ncontributor rather than \"whichever class arrives first\", and cover it with a\nchild-first/base-first equality test.\n\n### 18. The generator owns the index for its own default ordering (#2363)\n\nEvery generated list surface — REST, MCP, the SvelteKit list route — pages with\n`ORDER BY created_at DESC, <pk> ASC` (`DEFAULT_LIST_ORDER_BY`, #2367), and\nuntil #2363 no schema path indexed `created_at` (the AST path, deleted in\n#2380, indexed `updated_at`), so the framework's own default page was a\nsequential scan plus a top-N sort. `ensureDefaultListOrderingIndex()` now runs\non all four entry points and emits:\n\n- `(<tenant column>, created_at)` on a tenant-scoped table — the tenancy\n interceptor puts `tenant_id = ?` in front of every list, so the tenant column\n leads and `created_at` orders within it. This composite **replaces** the\n standalone tenant index from #2359: a B-tree serves every prefix of its\n column list, so `ensureDefaultListOrderingIndex()` is called first and\n `ensureReferenceColumnIndexes()` then sees the column as already served. The\n tenant column is found by `referenceKind === 'tenantId'`, never by the\n `tenant_id` spelling — `@smrt({ tenantScoped: { field } })` renames it.\n- `(created_at)` otherwise.\n\nThree deliberate omissions, so nobody \"fixes\" them later:\n\n- **No `DESC`.** `IndexDefinition` carries no per-column direction and\n PostgreSQL scans a B-tree backwards just as cheaply.\n- **No primary-key tiebreak column.** The default order mixes directions\n (`created_at DESC, id ASC`), so no single-direction index satisfies the whole\n key; the leading columns already turn a full sort into an index scan plus an\n incremental sort over rows sharing a timestamp.\n- **Not scoped per STI subtype.** `(_meta_type, created_at)` would serve a\n child collection's list but not the base class's polymorphic one, which\n carries no discriminator predicate — the same reasoning that keeps STI\n reference indexes plain (#2359). One unqualified index per shared table.\n\nAn existing UNQUALIFIED index that already leads with the same columns\nsuppresses it — a partial or JSON-path index never counts. That is how a\ndeclared `@smrt({ indexes: [...] })` composite (#2357) takes over: declaring\n`(tenant_id, created_at, status)` replaces the generated pair, while declaring\na different sort column such as `(tenant_id, publish_date)` sits **beside** it,\nbecause that index cannot order the default page. Declared indexes are appended\nbefore this helper for exactly that reason; anything that appends an index in\nfuture goes in the same slot, ahead of `ensureDefaultListOrderingIndex()` and\n`ensureReferenceColumnIndexes()`.\n\n### 19. One conflict-target rule, applied on every producer\n\n`save()` upserts on `ObjectRegistry.getConflictColumns()`; the schema must\ncarry exactly one unique index over those columns (or they must be the\nprimary key). Keep the derivation in `src/schema/conflict-target.ts` and let\nevery producer call it — the three manifest pipelines share\n`ManifestGenerator.applyGenerationPasses()` since #2360 because\n`ManifestBuilder` had silently skipped the report passes for months. When you\nadd a way for the key to vary (a new decorator option, a new class kind),\nthread it through `getConflictColumns()`, `normalizeConflictColumns()` and the\ngenerator's `resolveConflictTarget()` together, and extend the parity test's\n\"unique index == conflict target\" assertion; a key the runtime uses and the\nschema does not index is a hard PostgreSQL error (42P10) on the first save,\nand a key the schema indexes without the tenant column is the silent\ncross-tenant overwrite this rule exists for.\n\n### 20. Every generated index name is length-guarded before it leaves a path (#2374)\n\nPostgreSQL truncates any identifier past 63 **bytes** and reports nothing;\nSQLite and DuckDB do not, so the entire test suite was blind to it. The 66-byte\n`content_contribution_revisions_contribution_id_revision_number_idx` shipped\nthat way — only the differ's signature-equivalence check kept it from emitting\n`add_index` on every run. Two names agreeing for 63 bytes is the real hazard:\n`CREATE INDEX IF NOT EXISTS` no-ops against the wrong index, and the second\nindex is never created.\n\n`schema/index-utils.ts` owns the guard, and it splits by who owns the name:\n\n- **Generated index, trigger and PL/pgSQL function names** →\n `shortenIdentifier()`. Deterministic `<head>_<digest><suffix>`, digest taken\n over the **full** original so a shared prefix still yields distinct names, and\n a recognised suffix (`_idx`, `_unique_idx`, `_key`, `_pkey`) preserved.\n- **Hand-declared `@smrt({ indexes: [{ name }] })`** → `assertIdentifierFits()`,\n a hard error in `validateDeclaredIndex()`. Renaming what a developer wrote is\n worse than refusing it, and `SchemaComparer` matches indexes **by name**\n first, so a 70-byte declaration could never match the 63-byte index\n PostgreSQL stored and `db:migrate` would emit `add_index` forever.\n- **Table and column names** → deliberately **not** guarded. PostgreSQL\n truncates identifiers *consistently on every reference*: `CREATE TABLE\n \"<80 bytes>\"` and a later `SELECT ... FROM \"<the same 80 bytes>\"` both resolve\n to the same stored 63-byte name, so one long name round-trips fine end to end.\n `smrt-users` depends on this — it ships an intentional 80-byte\n `@smrt({ tableName })` (`permission_policy_table_name_that_is_far_too_long…`)\n and derives unique Postgres RLS policy names from it. An earlier revision of\n this rule hard-errored here on the theory that the runtime resolves tables by\n name and would break; that theory is wrong for the reason above, and the error\n broke `packages/users`. The residual collision risk is over a name the\n developer chose, not one the generator manufactured.\n\n`enforceIdentifierLimits()` is the single call site per path, placed **after**\n`ensureReferenceColumnIndexes()` — nothing may lengthen a name after it. Doing\nthe shortening at the end rather than at each `indexes.push()` is safe because\nthe digest covers the whole original name, so entries distinct before shortening\nstay distinct after; the helper still throws if two ever collide. The migrate\nleg's `withConflictIndex()` (`registry/schema-builder.ts`) and the PostgreSQL\ntrigger-function name call `shortenIdentifier()` directly, because they compose\na name outside the generator's index list. Note that an over-long *table* name\nstill yields in-limit, distinct *index* names, because the shortening runs over\nthe whole composed name.\n\nThe digest is FNV-1a, not `node:crypto`: `index-utils.ts` is re-exported from\n`schema/utils.ts`, which exists to keep Node built-ins out of browser bundles.\nIt only has to be *stable* — a shortened name that changed between releases\nwould make every deployment drop and recreate the index — so the parity and\nunit tests pin the literal output rather than recomputing it. Unpaired\nsurrogates are folded to U+FFFD before both counting and hashing, so the digest\nis taken over exactly the bytes the driver transmits.\n\nExisting databases migrate **by name swap, without a rebuild**: the live index\nstill carries the name PostgreSQL truncated it to, the manifest now carries the\nshortened one, and the differ claims it by signature (columns + uniqueness +\npredicate), emitting nothing — including under `includeDroppedIndexes`. See\n`migrations/__tests__/index-drift.test.ts` and the PostgreSQL lane test\n`schema/issue-2374-identifier-length-postgres.optional.test.ts`.\n\nOut of scope, deliberately: constraint names PostgreSQL invents for itself. A\nCTI table's inline `UNIQUE` produces an implicit `<table>_<column>_key`, which\ncan exceed 63 bytes even when the table and column each fit. SMRT never names\nit, and PostgreSQL disambiguates its own truncations by appending a counter\nrather than collapsing them, so there is no silent-collision hazard there.\n\n### 21. The `_smrt_` prefix does not mean \"system table\" (#2376)\n\n`bootstrapSystemTables()` owns nine hand-written tables; ~25 more `_smrt_*`\ntables belong to `@smrt()` models and are created by `db:migrate` (feature\nflags, prompt overrides, subscription plans, report schedules, field policies,\njobs). Never classify by prefix — use `SYSTEM_TABLE_NAMES`\n(`schema/system-table-shapes.ts`, derived from the DDL parse) plus\n`FRAMEWORK_OPERATIONAL_TABLES` / `RETIRED_SYSTEM_TABLES` in `system/schema.ts`.\nThe change-feed writer skipped by prefix, so clients syncing those domain\ntables through `_changes` never saw an update.\n\nEditing `ALL_SYSTEM_TABLES` requires bumping `SMRT_SCHEMA_VERSION` *and*\nappending to `SMRT_SCHEMA_DDL_CHECKSUMS` — the version gates the DDL replay, so\nwithout a bump no existing database ever applies the change. A new **column**\nadditionally needs an `addColumnIfMissing()` entry in `system/compatibility.ts`\n(`CREATE TABLE IF NOT EXISTS` is a no-op on an existing table).\n`system-schema-evolution.test.ts` enforces both, and asserts a legacy database\nupgrades to exactly the shape a fresh install gets.\n\n`_smrt_jobs` / `_smrt_job_events` are dual-owned: `db:migrate` creates them,\nthe compatibility pass reshapes them. On a fresh install bootstrap runs first,\nso their pass is deferred — `ensureDeferredSystemTableCompatibility()` re-runs\nuntil the tables exist, then stamps a `<version>+deferred-compat` marker. It\nruns OUTSIDE the bootstrap lock and swallows its own failures: those statements\ntarget tables the framework does not own, and inside the PostgreSQL transaction\none failure would roll back system-table creation with it. Only\n`ensureBootstrapSystemTableCompatibility()` (the tables the DDL itself creates)\nbelongs inside the lock.\n\nReconciling `_smrt_jobs.task_id` uniqueness reads the live index catalog, which\nis implemented for PostgreSQL and SQLite only; DuckDB and the JSON adapter keep\nthe redundant compat index rather than risk dropping the one that enforces the\nupsert conflict target. When reading a PostgreSQL catalog array, cast it\n(`attname::text`) and parse both shapes — a driver with no parser registered for\nthe array OID returns the raw `{a,b}` literal, and reading that as \"no columns\"\nsilently inverts an index-existence decision.\n\n## Same-package referential integrity uses two matching rails\n\n`@foreignKey(Target)` emits a physical database constraint when the target is\nin the same package and applies the same action through `SmrtObject.delete()`\nin `src/cascade.ts`. A shared delete-action resolver keeps both paths aligned;\nthe established generated `ON UPDATE CASCADE` default remains unchanged:\n\n| Reference | Default when `onDelete` is absent |\n|---|---|\n| Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |\n| Polymorphic `(metaType, metaId)` association row | `CASCADE` |\n| Ordinary same-package reference | `NO ACTION` — deletion is refused while references remain |\n| Every `@tenantId()` field | Excluded from physical constraints and delete cascades |\n\nThe natural-key rule is what cleans junction rows up without any per-package\nannotation: a junction declares\n`@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so the\nrow is *identified* by the content and cannot outlive it. An ordinary child\n(`Order.customerId`) is keyed by `(slug, context)` and therefore defaults to\nimmediate `NO ACTION` unless it opts in explicitly.\n\n**`@tenantId()` is excluded even though it lands in `conflictColumns`.**\n#2360 leads every tenant-scoped class's *default* natural key with the\ntenant column, so without this exclusion, deleting one `Tenant` row would\nrecursively CASCADE through every tenant-scoped table in the schema that has\nnot declared its own `conflictColumns` — the overwhelming majority. The\ntenant column scopes ownership; it does not identify the row the way a\njunction's foreign key does. Detected via the `__tenancy.isTenantIdField`\nmarker on `FieldMeta` (`smrt-core` reads it structurally so it never depends\non `smrt-tenancy`). `@tenantId()` exposes no `onDelete` option today, so\nthis cannot currently be overridden per field.\n\n`@crossPackageRef()` remains runtime-only: it registers relationship loading\nand indexes but deliberately emits no physical constraint, avoiding circular\npackage DDL. Tenant markers follow the same non-constraint rule because a\ntenant is a scope, not an ownership edge.\n\nFor a same-package relationship whose semantics are portable but whose physical\nconstraint shape is not, `@foreignKey(Target, { constraint: { engines: [...] } })`\nis the public exception. The allowlist scopes physical DDL and dependency\nplanning only. Relationship metadata, UUID representation, derived indexes, and\nthe application delete rail remain canonical on every engine. Empty or unknown\nengine lists fail closed; unannotated unsupported DuckDB cycles and actions keep\ntheir actionable refusal.\n\nEvery schema creation entry point uses the same deterministic dependency\nplanner. Parents are created before children. SQLite keeps cycle constraints\ninline because it can create them safely. PostgreSQL creates mutually dependent\ntables first and adds their named constraints afterward. DuckDB refuses cycles,\nself-references, `CASCADE`, and `SET NULL` with an actionable error because its\ncurrent ALTER/constraint support cannot enforce those shapes safely.\n\nFor existing tables, PostgreSQL checks the exact child table/column against the\nexact referenced table/column before adding a constraint as `NOT VALID` and\nthen validating it. The probe uses distinct child/parent aliases and, when both\nmanifest columns are UUIDs, bases its guarded casts on both live column types:\nmatching live types compare directly, while a legacy text side is shape-checked\nbefore casting. This keeps a self-reference or malformed legacy value from\ninvalidating the query. An orphan stops migration with detector SQL and an\nexecutable repair suggestion: nullable FKs are cleared, while required FKs\nrequire an explicit operator decision to reassign the reference or deliberately\nremove a child row after preserving its required data. A probe failure is\nsurfaced as a database/framework error, never misreported as orphan data.\nSQLite requires a deliberate table rebuild; DuckDB reports the unsupported ALTER\npath. Neither engine treats an unsupported constraint addition as a successful\nno-op.\n\n### Pre-R11 `text` ids converge to `uuid` before any FK statement (#2608)\n\nR11 made SMRT identifiers and references native `uuid` on PostgreSQL. A\ndatabase created before that change still stores its `id` columns as `text`\nwhile every reference column added afterwards materializes as `uuid`.\nPostgreSQL cannot implement a foreign key across two different physical types —\nFK DDL admits no cast — so `ADD CONSTRAINT … NOT VALID` fails with SQLSTATE\n42804 and aborts every later statement in the same migration batch.\n\nTwo rails handle it, and both are PostgreSQL-only. SQLite stores UUIDs as text\nby design and DuckDB cannot rewrite a column type in place, so neither engine\nemits anything for this drift.\n\n**The runtime guard fails closed.** `SchemaManager.ensurePostgresForeignKey()`\nreads both live column types and refuses to emit `ADD CONSTRAINT` when they\ndisagree, naming both columns, both live types, and the repair. It deliberately\nskips the orphan probe in that case: across mismatched types the probe answers\na question about casted values, not about the constraint being refused, and it\nhas to run again after the columns converge anyway.\n\n**The differ converges the columns.** `planUuidConvergence()`\n(`src/schema/uuid-convergence.ts`) groups every manifest relationship that\ndeclares UUID on both sides into connected components and converges a component\nonly when the live database already proves the target shape — at least one\nmember is native `uuid`. A component that is `text` on *every* side is the\ntolerated pre-R11 deployment and is left alone; its foreign keys are\ntype-compatible today, and the R11 uuid/text equivalence in\n`migrations/differ.ts` keeps it out of the column diff.\n\nComponents, not individual pairs, are the unit of decision: one legacy `text`\nprimary key can be referenced by several children, and converting it for one\nof them would break every sibling that is still `text`. A self-referential\ntable falls out of the same grouping because both endpoints land in one\ncomponent. Convergence is relationship-driven, so a legacy `text` id that\nnothing references keeps its R11 tolerance.\n\nThe planner never coerces data. Before emitting anything it probes each column\nit would rewrite for values that are not uuid-shaped (the same `~*` canonical\npattern the orphan probe uses) and refuses the whole component — with the count\nand a sample value — if any exist, if the probe cannot run, if a member carries\nsome third physical type, or if a live foreign key still constrains a column\nthat must change. `@happyvertical/sql` introspection does not expose live\nPostgreSQL constraint names, so SMRT cannot drop and re-add those constraints\nfor you: drop them deliberately, rerun the migration to converge, and let SMRT\nre-add the manifest constraints.\n\nRefusals are reported, not silent. Each one becomes a warning advisory with no\nexecutable SQL, so it reaches `unactionableChanges` / `hasManualDrift` and\n`db:status` shows **blocked: incompatible column types** instead of *pending*.\nThe same check runs per relationship in `compareForeignKeys`, so a foreign key\nwhose live types will still disagree after this run's conversions is reported\nblocked rather than emitted as pending DDL that cannot succeed.\n\nThe planner also inspects tables the manifest no longer declares. A live\nforeign key from an orphan table onto a column that must convert still blocks\n`ALTER COLUMN … TYPE`, so the differ introspects every existing table — not\nonly the manifest ones — whenever there is at least one conversion candidate,\nand reports the dependency instead of emitting DDL PostgreSQL would reject. An\nalready-converged database has no candidates and pays nothing.\n\nOrdering is a contract. Conversions carry `SchemaChange.phase =\n'pre_foreign_key'`, and the orchestrator emits them **before every CREATE TABLE\nand every foreign-key statement in the batch**. Both halves matter:\n`planForeignKeyCreation()` only defers the constraints inside a mutual cycle,\nso an acyclic new child table keeps its foreign key *inline in `CREATE TABLE`*\n— a brand-new `uuid` child pointing at a legacy `text` parent fails exactly\nlike an existing one, before the parent could be converted. Conversions only\never rewrite columns that already exist, so leading the batch is always safe. A\nlive `DEFAULT` on a converting column is dropped first (PostgreSQL refuses\n`ALTER COLUMN … TYPE` when the default cannot be cast); the ordinary default\ncomparison re-establishes the manifest default on the next run.\n\nThere are **two** batch builders and both order on that marker:\n`collectStatementsFromDiff()` in `migrations/orchestrate.ts` (used by\n`getPendingSchemaStatements` / `migrateSmrtSchemas`) and the tracker batch\n`db:migrate` assembles by hand in `@happyvertical/smrt-cli`\n(`commands/utilities.ts`). `partitionSchemaChanges()` carries\n`SchemaChange.phase` onto `MigrationAction.phase` so the CLI can partition the\nsame way, in both the applied batch and the `--dry-run` preview. If you add a\nthird consumer, order it the same way.\n\nConvergence entries carry the manifest column definition. Every `type_upgrade`\nconsumer reads `SchemaChange.column` — `partitionSchemaChanges()` in\n`@happyvertical/smrt-cli` skips an entry without one — so a conversion missing\nit would drop out of the `db:migrate` batch while `compareForeignKeys()` still\nassumed the converged type. Refused convergences carry the same column plus an\nadvisory and no SQL, and the CLI routes them to the report-only advisories\nrather than to manual interventions or the tracker.\n\nThe uuid wording is gated on the manifest. Both the runtime guard and the\nstatus planner reach their incompatible-type branch for *any* mismatched pair,\nnot only uuid/text. A `USING …::uuid` repair is suggested only when the\nmanifest declares UUID on both sides **and** a live side is actually `text`;\notherwise the diagnostic names the two live types and asks the operator to\nalign them deliberately.\n\nThe conversion is one-time and idempotent: once the column is native `uuid`,\nthe component is uniformly UUID and the planner emits nothing.\n\nProperties to keep if you touch that module:\n\n- **The plan is registry-derived and rebuilt per delete.** Registration is\n incremental — manifests load lazily and tests register classes between cases —\n so a cached plan would silently skip a table that registered later. Cache it\n only behind an invalidation hook that every registration path calls.\n- **A class with nothing pointing at it skips the transaction entirely — but\n `CascadePlan.isEmpty` requires no polymorphic association class anywhere in\n the process, not just no typed references.** `buildCascadePlan()` pushes\n *every* registered `SmrtPolymorphicAssociation` subclass into\n `plan.polymorphic` unconditionally (`cascade.ts` around\n `isPolymorphicAssociationClass`): a `metaType` column can point at any class\n at runtime, so there is no static metadata to scope it by the target being\n deleted. One registered polymorphic class anywhere makes `isEmpty` false for\n every delete in that process — do not read \"the common case skips the\n transaction\" as \"most deletes in a real app skip it\"; in a multi-package app\n that registers even one polymorphic association, almost none do.\n `runCascadeDelete()` builds the plan for `getResolvedQualifiedName()` (not the\n bare constructor name — two packages can register the same simple name).\n- **Cascaded rows are removed set-based.** Their `beforeDelete`/`afterDelete`\n hooks and interceptors do not run and no change-feed tombstone is written for\n them, which is exactly what a DB-level `ON DELETE CASCADE` does. Only the\n object `delete()` was called on runs the lifecycle. Do not \"improve\" this into\n a per-row model delete without deciding what that means for sync consumers.\n- **Everything is one transaction where the adapter has one**, including the\n object's own `DELETE`, whenever there is anything to cascade. The `RESTRICT`\n checks run first, before any mutation, so a refusal costs nothing; the\n transaction is what makes a refusal *deeper* in the graph safe.\n- **`_smrt_embeddings` and `_smrt_contexts` are matched by id *and* a\n class-name candidate set, not id alone.** Their class columns store the\n *runtime* constructor name, which for an STI hierarchy is a concrete\n subclass rather than the class the cascade planned from — id-alone matching\n looked STI-safe, but let two unrelated classes using `idType: 'text'`\n (non-UUID, not guaranteed globally unique) collide on a shared id value and\n delete each other's rows (review fix). `ownerClassCandidates()` expands to\n every STI hierarchy member of the class the ids actually belong to, in both\n qualified and simple form. A failure to clean them is logged, never raised —\n an application database may predate the table, and losing derived rows must\n not fail a valid delete.\n\n`_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals` and the dispatch tables are\ndeliberately **not** cascaded. They are append-only logs; the change feed in\nparticular receives the delete's own tombstone, so cascading it would erase the\nrecord that tells sync clients the row is gone.\n\n### 22. System tables get a retention policy, not just a prune function (#2375)\n\nFour framework-owned tables grow with traffic and nothing used to remove a row:\n`_smrt_changes` (one per save/delete), `_smrt_ai_usage` (one per AI call, and\npersistence is on by default), `_smrt_contexts` (whose `expires_at` nothing\nenforced) and `_smrt_dispatch` (an operator-only `dispatch:cleanup`).\n`src/system/retention.ts` is now the single place that bounds them.\n\n- **`runRetentionSweep(db, policy)` is the entry point.** It runs the four\n built-in tasks in a fixed order, then every task other packages contributed\n via `registerRetentionTask()` — `@happyvertical/smrt-jobs` registers\n `_smrt_jobs`/`_smrt_job_events`, `@happyvertical/smrt-users` registers\n session/magic-link/CLI-auth expiry. A task that throws is recorded on its own\n result and the sweep continues; a missing table reports `unavailable`, so a\n sweep is safe against a partially bootstrapped database.\n- **A contributed task only exists in a process that loaded its package.** Both\n packages register on import from their entry point, and the registry lives on\n `globalThis` (like `ObjectRegistry`) so a duplicated `smrt-core` resolution\n cannot split it. `smrt db:prune` optionally imports both packages for exactly\n this reason — a project that installs neither correctly gets neither task.\n- **Defaults are opt-out, not opt-in.** `DEFAULT_RETENTION_POLICY` covers the\n four built-in tables (changes 30 days, AI usage 90 days, dispatch 30 days\n completed / 90 days failed, contexts strictly by their own `expires_at`), and\n those are the ones `smrt.configure({ retention })` tunes. Contributed tasks\n carry their own defaults and their own window options —\n `DEFAULT_JOB_RETENTION` (7 days terminal / 30 days failed / 30 days events,\n set through `registerJobRetentionTasks()` or the runner's `retention.jobs`),\n and expired credentials, which have no window because an expired credential\n has nothing worth retaining. Every task, built-in or contributed, can be\n turned off: a table set to `false`, a task set to `false` under `tasks`, or\n `enabled: false` for the whole sweep — through `smrt.configure`,\n `smrt db:prune --skip`, or the runner's `retention` config.\n- **Contributed task names are prefixed with the owning package's short name**\n (`jobs-records`, `jobs-events`, `users-sessions`, …) because the registry is\n one process-global namespace.\n- **Scheduling lives outside core.** A running `TaskRunner` sweeps every six\n hours (`retention: false` opts out) and `smrt db:prune` is the cron entry\n point. The first runner sweep is one interval after `start()`, never at\n start: a crash-looping worker must not become a delete loop.\n- **Every prune counts before it deletes.** `rowCount` is not reliably\n populated across the engines SMRT supports, so counting is both what gives a\n usable figure and what lets `dryRun` preview the *same* predicate rather than\n an approximation of it. Count and delete are two statements and deliberately\n not one transaction — a maintenance pass must not hold a write lock over a\n large delete — so the figure is approximate under concurrent writers. Where\n two bounds can select the same row (`pruneChangeFeed`, `pruneAiUsage`), the\n second bound excludes what the first already accounted for, so a dry run does\n not count an entry twice.\n- **Every retention predicate ships with its index** (rule 2 applies to\n maintenance SQL too): `_smrt_contexts(expires_at)`,\n `_smrt_ai_usage(tenant_id, created_at)` — which is also the subscriptions\n billing meter's range scan — `_smrt_dispatch(status, processed_at)` and\n `(status, updated_at)` come from the system DDL, so they reach existing\n databases through the `SMRT_SCHEMA_VERSION` bump that replays it.\n `_smrt_jobs(status, completed_at)` comes from\n `ensureJobsSystemTableCompatibility()` instead, because `_smrt_jobs` is\n generated from a decorated class and does not exist yet when bootstrap runs;\n the jobs collection calls that path on every `initialize()`.\n- **Expiry enforcement is prune-side only.** `recall()`/`recallAll()` keep\n their documented \"expiry is not applied at read time\" contract — changing it\n would change read semantics for existing callers, which is a different issue\n from bounding storage. `LearningMemory` filters expired rows itself.\n\n### 23. Dead generation surfaces were deleted, not wired (#2380)\n\nRule 9 named three surfaces that read as canonical but were not: the AST\n`generateSchema(objectDef)` entry point, `SchemaOverrideSystem`, and the\nnever-emitted `triggers: []`. Resolution, so a future agent does not re-open\nwhat was deliberately decided:\n\n- **The AST path is gone.** `SchemaGenerator.generateSchema(objectDef)` and its\n AST-only private helpers (`generateIndexes`, `generateTriggers`,\n `extractDependencies`, `generateVersion`, `getTableName`,\n `extractPackageName`) were deleted from `schema/generator.ts`, along with\n their sole caller, `generateSchemaModule()` in `vite-plugin/index.ts`, and the\n `smrt:schema` / `@happyvertical/smrt-virt-schema` virtual module registration\n that fed. Nothing else called it — grep the deleted method's exact name\n before assuming a caller was missed; the path-parity fixture and every other\n rule above already speak only of the four surviving entry points.\n- **`SchemaOverrideSystem` is gone**, file and all\n (`schema/override-system.ts` no longer exists). It was never called from\n anywhere in this repository outside its own now-deleted exports, and two of\n its five public methods (`createPraecoContentOverride`,\n `createPraecoMeetingOverride`) hard-coded a schema extension for a\n consuming project outside this monorepo — scaffolding that never belonged in\n the framework, not a generic feature with a missing caller. `SchemaOverride`\n (the type) went with it; `ColumnDefinition`/`IndexDefinition`/\n `TriggerDefinition`, which it merely referenced, did not.\n- **The DDL-strategy trigger machinery was kept, not deleted.**\n `TriggerDefinition`, `SchemaDefinition.triggers`, and every DDL strategy's\n `generateTriggers()` / `generateTriggerStatement()` / `supportsTriggers()`\n (`schema/ddl/*.ts`) are real, engine-uniform, directly-tested rendering code\n that runs on **every** table creation via `strategy.generateTriggers(schema)`\n — unlike the AST path, this is not an orphaned call graph. It is kept for the\n same reason rule 16 keeps the cached `schema.ddl` string: `SchemaDefinition`\n is part of the shape third-party tooling and published manifests may already\n depend on, and `EngineSpecificDDL`/`MultiEngineDDL` (`schema/ddl/types.ts`)\n carry `triggers` as part of that same contract. Deleting a published field is\n a different (and unjustified) risk from deleting a virtual module nothing\n ever imported.\n- **What changed is what is documented, not what runs.** `schema.triggers` is\n now explicitly documented (`schema/types.ts`) as always `[]` on every schema\n a `@smrt()` class can produce, and why: there is no `@smrt()`/`@field()`\n option that populates it (unlike `indexes`, #2357), `updated_at` is\n maintained at the application layer (`SmrtObject.save()`), and\n `migrations/differ.ts` never diffs triggers — so even a hand-populated one\n would only apply to a newly `CREATE TABLE`d table and never retrofit an\n existing one. Wiring live trigger emission was considered and rejected for\n this issue: it is a migration-rollout feature (retrofitting 238+ existing\n production tables needs the same `SMRT_SCHEMA_VERSION`-replay or differ\n support rule 21/rule 22's system-table work required), not a cleanup, and\n nothing in the epic depended on it the way #2359 depended on FK indexes\n actually shipping.\n- **`_smrt_signals` and `ObjectRegistry.persistToDatabase()`/`loadFromDatabase()`**\n — named in the original finding alongside triggers — were already handled by\n #2376 before this issue landed: see rule 21 and `system/schema.ts`'s\n `RETIRED_SYSTEM_TABLES`. Nothing further to do there.\n- **The two config-rebuild-site comments** (`schema/utils.ts`,\n `testing/database.ts`) rule 8 requires were already in place, added by\n #2357/#2360; the `testing/database.ts` \"same as migrations\" overclaim rule 1\n quotes was already corrected by #2359, and doctor's `experimentalDecorators`\n check was already fixed by #2368/#2399 (see `packages/cli/AGENTS.md`\n Gotchas). Re-verify against current source before repeating any of these —\n the epic's PRs landed across one evening and a stale assessment line is not\n proof a fix is still needed.\n"
|
|
1002
|
+
"path": "agents/query-bounds.md",
|
|
1003
|
+
"module": "query-bounds",
|
|
1004
|
+
"content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Query bounds (#2367)\n\n`src/query-bounds.ts` is the single parser every generated read surface uses for\n`limit`/`offset`. A bound is a non-negative integer or it is a client error:\nmalformed input raises a 400-typed `QueryBoundsError` (a `ValidationError`, so\n`withRetry` never retries it and `normalizeTypedHttpError` renders it as a\nstructured 400) instead of reaching the driver as `LIMIT NaN`. Oversized pages\nare **clamped** to `MAX_LIST_LIMIT` (1000) rather than rejected, and an explicit\n`0` means zero rows — it is not folded into `DEFAULT_LIST_LIMIT` (50).\n\n- `collection.get()` / `findOne()` / `findById()` emit `LIMIT 1`.\n- `collection.list()` validates `limit`/`offset` but applies **no implicit\n default**: it is the framework's bulk-read primitive and relationship,\n junction, hierarchy and `listByIds()` callers all expect every matching row, so\n a framework-wide default would truncate correct queries. Applications opt in\n per collection with `defaultListLimit` / `maxListLimit` (validated at\n construction). The generated surfaces enforce the ceiling on their own\n untrusted input regardless.\n- `orderBy` runs the same rail as `where` (#1540) and `select` (#1902): terms are\n checked against the field whitelist and refused for `@field({ sensitive: true })`\n and `@field({ readPermission })` columns — ordering is a comparison, and\n `?orderBy=api_secret&limit=1` is an oracle over a column the request may neither\n filter on nor project — and for fields that are registered but **not\n column-backed** — `oneToMany`/`manyToMany`/`meta`/`transient`, plus the\n `id`/`slug`/`context` system columns on a custom-primary-key class, which the\n schema generator omits — all of which otherwise reach the driver as\n `no such column` and surface as a 500. The whitelist is skipped only for\n manifest-less inline test classes (#869), exactly as `where` skips it. `where`\n still whitelists those omitted system columns; that is a pre-existing gap of\n the same family, not closed here because `where: { id }` is on internal\n hydration paths.\n- Every generated list surface (REST, SvelteKit, MCP, and the emitted stdio MCP\n runtime) pages with `ORDER BY created_at DESC, <pk> ASC` unless the caller\n supplies `orderBy` — `LIMIT`/`OFFSET` with no ordering is not pagination, and\n `created_at` alone still ties. `<pk>` follows a declared\n `@field({ primaryKey: true })` (read from `_meta.primaryKey` in manifests)\n because custom-primary-key classes have no synthetic `id` column. The stdio\n runtime gets the per-object ordering baked in via `RuntimeOptions.listOrderBy`;\n it cannot resolve a primary key on its own. Index: #2363.\n- `listByIds()` chunks its `IN` list at `IN_LIST_CHUNK_SIZE` (900), like the\n relationship/junction/hierarchy loaders.\n\nKeyset pagination is deliberately out of scope.\n"
|
|
1003
1005
|
},
|
|
1004
1006
|
{
|
|
1005
1007
|
"path": "agents/data-query.md",
|
|
@@ -1007,24 +1009,34 @@
|
|
|
1007
1009
|
"content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Bounded data queries (#2444)\n\n`normalizeDataQueryRequest()` and `normalizeDataQueryResult()` define the trust\nboundary for the transport-neutral table/report/content query envelope. A\ntrusted adapter supplies `DataQuerySchema`; callers receive only its declared\nprojectable, sortable, filterable, and facetable fields. The helpers validate\nand normalize but never execute SQL or decide tenant/principal access.\n\nUse `createDataQueryFingerprint()` for cache and result correlation. It omits\nrequest id and page position, canonicalizes equivalent filter/projection/facet\nforms, and adds the identity sort tie-break. Keep values scalar and request,\npage, and facet sizes positive and bounded. Results must stay within the schema\nbyte cap with declared field types preserved; datetimes are RFC 3339 instants,\nidentity fields are string/number/datetime-compatible, and JSON values are\nbounded by depth, container count, string size, and bytes before cloning.\n\nOnly normalized `DataQueryResult` envelopes cross REST, MCP, WebMCP, and browser\nboundaries. Adapter-specific report/content context wraps the base envelope; it\ndoes not add unsafe fields or SQL-like controls.\n"
|
|
1008
1010
|
},
|
|
1009
1011
|
{
|
|
1010
|
-
"path": "agents/
|
|
1011
|
-
"module": "
|
|
1012
|
-
"content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Collection reads\n\nThis module covers bounded collection reads beyond the basic query contract in\n`packages/core/AGENTS.md`.\n\n## Projections and related rows\n\n`list({ select })` uses SMRT field names, maps them to database columns, and\nreturns plain rows without hydrating objects. It composes with `where`,\n`orderBy`, `limit`, and `offset`, runs normal `beforeList`/tenant interceptors,\nand is limited to column-backed fields; it cannot combine with `include`.\n\n## Bounded STI discriminator scopes\n\nAn STI child collection remains scoped to its own qualified `_meta_type` by\ndefault. A migration that must read registered sibling types may opt into an\nexplicit allowlist:\n\n```typescript\nawait impressionEvents.list({\n stiScope: {\n types: [\n '@anytown/advertising:AdImpression',\n '@anytown/advertising:LegacyAdImpression',\n ],\n },\n orderBy: 'created_at ASC',\n limit: 100,\n});\n```\n\n`stiScope.types` accepts 1–50 unique, qualified, registered types, all sharing\nthe child collection's STI root. Empty, simple-name, unknown, duplicate,\nunrelated, and non-child scopes fail at the collection boundary. The option is\nsupported by `list()`, `count()`, `counts()`, `facets()`, and\n`listWithLatestRelated()`. These methods retain their normal field validation,\nprojection or polymorphic hydration, pagination and cache-key construction;\nnormal read and tenant interceptors still run and are ANDed with the allowlist.\nPoint reads through `get()` remain child-only; use a bounded\n`list({ where, limit: 1, stiScope })` migration read when sibling hydration is\nrequired.\n\nFor one child per parent, use\n[`latest-related.md`](latest-related.md). It uses a portable ranked CTE,\ndeclared primary keys, adapter-specific offset-only syntax, explicit aliases,\nand hydrates only the visible parent page.\n\n## Facets, counts, and read plans\n\n`collection.facets({ fields, where })` runs one bounded `GROUP BY` per requested\nfield and returns `{ field, values: [{ value, count }] }`. It accepts at most 20\nfields, clamps value limits to 1,000 and the collection ceiling, never hydrates\nobjects, and applies the same read/tenant/sensitive-field rails as `select`.\nStored array/string-list values are grouped as stored; they are not unnested.\n`collection.counts({ where })` returns `{ total, filtered }` through two scoped\n`COUNT(*)` queries. Local coverage is SQLite/DuckDB; optional scalar PostgreSQL\ncoverage requires `SMRT_TEST_POSTGRES_URL`.\n\n`executeCollectionReadPlan()` bounds concurrent reads across independent\ncollections while preserving the normal registry and collection options. The\ncaller supplies a positive `maxConcurrency`; the executor does not compose SQL,\ncache, or alter pool defaults, and drains already-started work before returning\nthe first error.\n\n`where` operators must remain aligned with `@happyvertical/sql`'s `buildWhere`:\n`=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, and `like`. Arrays imply `IN`,\nand null values render `IS NULL`/`IS NOT NULL`. `contains` and dot-notation JSON\npaths are intentionally rejected until the SQL layer supports them.\n"
|
|
1012
|
+
"path": "agents/schema-paths.md",
|
|
1013
|
+
"module": "schema-paths",
|
|
1014
|
+
"content": "# smrt-core/schema paths\n\nModule semantics for `src/schema/` — which `SchemaGenerator` entry point reaches\na real database, what each one emits, and the rules that keep them in step.\nPackage orientation, the cross-module invariants, and the traps that apply\nbefore editing anything live in [../AGENTS.md](../AGENTS.md) — read that first;\nit links the relevant runtime and generation contracts.\n\n## Four entry points, two of which ship\n\n`src/schema/generator.ts` exposes four index-emitting entry points. Their columns and indexes must agree for the same class.\n\n| Entry point | Selected by | Status |\n|---|---|---|\n| `generateSTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateCTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |\n| `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |\n\nProduction DDL takes the manifest route:\n\n```\n@smrt() class ─▶ scanner ─▶ manifest.json ─▶ generate{STI,CTI}SchemaFromManifest\n ─▶ registered `schema` ─▶ ObjectRegistry.getAllSchemasAsDefinitions()\n ├─▶ smrt db:migrate | db:diff | db:status\n │ (the CLI drives SchemaComparer + MigrationTracker directly)\n └─▶ migrateSmrtSchemas() / getPendingSchemaStatements()\n (src/migrations/orchestrate.ts — exported for programmatic\n use; no in-repo caller outside its own tests)\n```\n\nSince #2359 the two families share one set of index helpers and\n`src/schema/schema-path-parity.test.ts` runs the same fixture manifest through\nthe manifest paths, through `ObjectRegistry.registerFromManifest()` + the\nregistry paths, and through `getAllSchemasAsDefinitions()`, asserting identical\ncolumn and index sets. Extend that fixture with every generator change; a\ndivergence is a bug in the generator, not an exception to add to the test.\n\n### Index rules (#2359)\n\n- **Reference columns are always indexed.** `ensureReferenceColumnIndexes()`\n runs last on every path and gives each `@foreignKey`, `@crossPackageRef` and\n tenant column `<table>_<column>_idx` unless an UNQUALIFIED index (no `WHERE`,\n no JSON path) already leads with it — the `conflictColumns` unique index or an\n `indexed: true` opt-in, or the column's own inline UNIQUE. A partial\n `WHERE _meta_type = …` index does not count: base-class polymorphic queries\n carry no discriminator predicate. `indexed: true` on a reference column is\n redundant. Roll the index wave out to production with\n `smrt db:migrate --postgres-safe` (concurrent-index mode, #2362): a plain\n atomic batch takes SHARE/ACCESS EXCLUSIVE locks for ~230 index builds. STI FK indexes are plain, one per\n column, not per-class partial.\n- **No index on the primary key.** `<table>_id_idx` is gone from every path,\n and `conflictColumns` equal to the PK column set emit no conflict index\n (`ON CONFLICT (id)` binds to the PK constraint). `SchemaComparer` drops the\n legacy non-unique single-column PK index from existing databases without\n `--drop-indexes` when the live table reports that column as its sole primary\n key (never a UNIQUE one — on PostgreSQL that may back a custom-named PRIMARY\n KEY constraint, and `DROP INDEX` on it would fail the atomic batch).\n- **Slug loading keeps its index.** Custom `conflictColumns` replace the\n `(slug, context)` unique index; `loadFromSlug()`/`getId()`/`getSavedId()`\n still filter on slug/context, so a plain `<table>_slug_context_idx` is kept\n (additive; routing those lookups through the conflict key would change which\n row a slug resolves to). The tenant-led default key below counts as serving\n it (`servesSlugLookup()`): a tenant-scoped slug lookup carries the tenant\n predicate (#2365) and is served by the prefix, so no second index.\n- **Tenant default keys** are `(tenant_id, slug, context)`, plus `_meta_type`\n for STI. `ManifestGenerator.normalizeConflictColumns()` and\n `ObjectRegistry.getConflictColumns()` share `src/schema/conflict-target.ts`:\n resolve tenant fields through the schema owner/STI root, report group/bucket\n columns through the report, and custom PKs through their key. Explicit\n `conflictColumns` remain unchanged. The manifest, schema, knowledge, and\n runtime must carry the same value.\n Names remain `<table>_slug_context_idx` / `_slug_context_meta_type_idx`, so\n migration replaces a same-name global unique with tenant-led columns. That\n prefix serves tenant and tenant-scoped slug reads; a legacy standalone tenant\n index is dropped only with `--drop-indexes`.\n- **Optional NULL tenants** dedup through SDK null-aware upsert (PostgreSQL\n `IS NOT DISTINCT FROM` plus advisory lock; SQLite process lock), not the\n unique index: raw SQL can duplicate NULL-tenant keys. Raw global inserts need\n `WHERE NOT EXISTS` and a PostgreSQL advisory lock; an old global `ON CONFLICT`\n target no longer binds. Save serializes an unset tenant explicitly as NULL,\n because every conflict column must be present. PostgreSQL `NULLS NOT DISTINCT`\n remains a potential follow-up, not current enforcement.\n- **Tenant-key rollout requires a maintenance window.** Old code/new indexes\n and new code/old indexes both fail new-object saves because conflict column\n sets must match exactly; persisted ID-based saves still work. Backfill legacy\n NULL tenants first or scoped ingestion creates separate rows and cannot see\n the old global ones. Cross-tenant natural-key dedup now creates one row per\n tenant. Deploy code and migrate together in atomic mode: each table drops\n and recreates its same-name unique index, holding ACCESS EXCLUSIVE locks\n (including against reads) until commit. Size `statementTimeout` for the\n largest table. A valid old subset unique guarantees the superset build;\n missing/nonunique old indexes may contain duplicates and need dedup first.\n Include the reference-index wave in that atomic window. `--postgres-safe` is\n suitable for an additive reference-index-only wave, but a key replacement\n leaves a per-table gap between drop/build and a failed build leaves no arbiter\n until rerun. There is no automatic DOWN; reverting code requires deliberately\n recreating its old indexes.\n\n- **STI `@field({ unique: true })` is enforced through indexes** (the differ can\n add an index to an existing table, never a column constraint): a full\n `<table>_<col>_unique_idx` when the STI base declares it, one\n `<table>_<col>_<class>_unique_idx WHERE _meta_type = '<qualified>'` per class\n when only descendants do — uniqueness per concrete class, not across the\n subtree. DuckDB/JSON have no partial indexes, so the descendant-scoped shape\n (`isStiSubtypeUniqueIndex`) is not emitted there — degrading it to a full\n UNIQUE would constrain every subtype; the DDL strategy and the differ both\n skip it, while other partial indexes keep degrading to full ones as before. Remember the\n framework serializes an unset text field as `''`, so a unique optional text\n field must be `nullable: true` with a `null` initializer or every unset row\n collides.\n- **Every class in an STI hierarchy carries the schema of the one shared\n table**, generated from the root base (`ManifestGenerator.generateSchemas()`\n resolves the root through `findSTIBaseInfo`), so a child never treats its own\n descendant-only unique field as base-declared.\n\n`src/schema/utils.ts` sits in between, and the two exports differ:\n\n- `generateSchema()` (reached from `SmrtCollection.generateSchema()`) always\n rebuilds from the registry and writes the result back into the registry,\n replacing whatever the manifest registered for that class.\n- `ensureSchema()` (reached from the deprecated `smrt db:setup`) is\n manifest-first: it takes `ObjectRegistry.getSchema()` plus the merged\n `getAllSchemasAsDefinitions()` table definition, and only falls back to\n `generateSchema()` when no schema is registered at all.\n\n## Verification\n\nExtend `src/schema/schema-path-parity.test.ts` for every generator change;\nmanifest, registry, and merged migration schemas must agree. Inspect regenerated\n`dist/manifest.json` and schemas across affected packages, not only decorators.\nRuntime `verifyPersistenceTable()` checks table existence only. Database drift\nchecks compare with generated artifacts; they cannot detect an omission shared\nby those artifacts. Use `smrt doctor --db` / `db:status --parity` for live parity.\n\nEvery new query predicate needs its index or an explicit reason none is needed.\nRun `pnpm --filter @happyvertical/smrt-core test:postgres` for numeric types,\nUUID casts, conflict targets, timestamps, or migrations. Schema-affecting options\nmust reach `SchemaGeneratorConfig` and both config rebuild sites:\n`src/schema/utils.ts` and `src/testing/database.ts`.\n\nTenant uniqueness and conflict targets must include the tenant column; explicit\n`conflictColumns` are author-owned and never rewritten. All reads, including\nhydration, slug lookup, vector search, and memory, remain interceptor-aware.\nRetry only transient errors classified through the cause chain; never retry an\naborted PostgreSQL transaction (`25P02`).\n\n### Composite indexes are declared, not inferred (#2357)\n\nThe generated set only covers foreign keys, unique/conflict columns, the STI\ndiscriminator, reference columns (#2359), default list ordering, and single columns opted in with `@field({ indexed: true })`. A list\nworkload's access path is composite, so declare it:\n\n```ts\n@smrt({\n indexes: [\n { name: 'contents_tenant_id_publish_date_idx',\n columns: ['tenantId', 'publish_date'] },\n ],\n})\n```\n\n`columns` takes field names or column names in access-path order — filter\ncolumns first, sort column last. Declare columns, not a direction: PostgreSQL\nscans a btree either way, so an ascending index also serves the matching\n`ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`\n(partial index) are honoured.\n\n`appendDeclaredIndexes()` runs first on all four entry points, ahead of\n`ensureDefaultListOrderingIndex()` (default ordering below) and `ensureReferenceColumnIndexes()`,\nso a declared composite leading with the tenant column (or any reference column)\nreplaces the automatic standalone index rather than duplicating it.\nUnknown columns, malformed entries, and a name collision with a different index\nall fail generation — a silently dropped index only surfaces later as a\nproduction slowdown. Keep both config rebuild sites aligned.\n\n### Relationship targets resolve to a class name on both paths\n\n`@foreignKey`/`@oneToMany`/`@manyToMany` accept a class, a name string, or a\n`() => Target` thunk. The decorator invokes the thunk and throws when the target\ncannot be resolved (never `related: ''`); the scanner unwraps the same thunk\nfrom raw source (never `related: '() => Target'`). An unresolved target silently\ncosts the relationship edge, `loadRelated()`, and the FK-derived index (#2379).\nA thunk resolves at decoration time, so a target declared later in the same\nmodule is still in its temporal dead zone — use the string form there.\n\n### A SQLite type change is a table rebuild (#2370)\n\nSQLite has no `ALTER TABLE ... ALTER COLUMN ... TYPE`, so\n`src/migrations/sqlite-rebuild.ts` answers a `type_upgrade` on SQLite with the\nstatement list SQLite's own docs prescribe: stage a new table under\n`_smrt_rebuild_<table>`, copy, drop, rename, replay the indexes and triggers.\n`SchemaComparer.compareTable` swaps that plan in for the differ's\n\"requires table recreation\" placeholder, so `db:migrate` applies it inside the\nnormal atomic batch instead of exiting 1 forever.\n\nFour properties of that module are load-bearing; keep them if you touch it:\n\n- **The target shape comes from the live `sqlite_master` DDL**, retyping only\n the drifted columns. It is not regenerated from the manifest, so the rebuild\n never becomes an implicit `DROP COLUMN`, and it preserves table constraints,\n `CHECK`s, and `WITHOUT ROWID`/`STRICT`.\n- **The rebuild is hoisted ahead of the table's other column changes.** Its\n staging DDL and copy list are captured at diff time, and the differ emits\n changes in manifest field order, so a new field declared above the retyped\n one would otherwise run `ALTER TABLE ... ADD COLUMN` first and have the\n rebuild silently drop it — both statements succeed and the batch commits.\n Rebuild first, then add columns to the rebuilt table.\n- **The copy carries no `CAST`.** SQLite applies the destination column's\n affinity on insert — the same conversion a fresh table performs. An explicit\n cast is worse: non-numeric TEXT cast to REAL/INTEGER silently becomes `0`,\n and an ISO timestamp cast to NUMERIC-affinity `DATETIME` becomes its year.\n- **It refuses when any table has a foreign key onto the target and\n `PRAGMA foreign_keys` is ON** (the SMRT adapter's default). `DROP TABLE`\n performs an implicit `DELETE FROM` that fires `ON DELETE CASCADE` on\n children, and `defer_foreign_keys` defers constraint *checks*, not FK\n *actions* — verified: the child rows go. The target's own self-reference\n counts, because the staging table copies that clause and becomes a child of\n the table being dropped (verified: a two-row self-referencing table finishes\n the rebuild holding one row). Such a column stays manual drift.\n- **`PRAGMA legacy_alter_table` brackets the rename**, because SQLite ≥ 3.25\n re-parses the schema on `ALTER TABLE ... RENAME` and a view still pointing at\n the just-dropped table makes it fail outright. It is restored immediately\n after; a rolled-back batch leaves it set on that connection, which is inert\n here only because nothing else in SMRT renames a table.\n\nAll the drifted columns of one table share a single rebuild: the first change\ncarries the plan and the rest become `no change needed` comments that the CLI\nclassifies as no-ops.\n\n## What the differ compares (#2369)\n\n`SchemaComparer` (`src/migrations/differ.ts`) compares each manifest column's\ntype, then — unless the type itself is drifting — its nullability and default,\nand always reports what it will not touch:\n\n- **Strengthening** (`SET NOT NULL`, `SET DEFAULT`) is executable on\n PostgreSQL/DuckDB. `SET NOT NULL` is preceded by an `UPDATE … WHERE c IS NULL`\n backfill of the manifest default; without a default the live data is probed\n and, if NULLs exist, the change is reported (comment SQL + `advisory`) instead\n of emitting an ALTER that would abort the atomic batch.\n- **Relaxing** (`DROP NOT NULL`, `DROP DEFAULT`) is a report-only advisory until\n the caller passes `relaxColumns` (`db:migrate --relax-columns`). The manifest\n can be under-specified (#2372 registration-order weakness), so a live column\n that is stricter than the manifest is never weakened silently.\n- **Orphans** — DB columns absent from the manifest, DB tables no manifest\n declares (`SchemaDiff.orphan_tables`), and unclaimed `*_key` unique constraint\n indexes — are always reported. A NOT NULL orphan without a default is a\n `warning` advisory (every ORM insert fails on it); `includeDroppedColumns`\n (`--drop-columns`) drops it, `relaxColumns` relaxes it. Advisory-only changes\n carry no SQL, never reach the tracker, and do not fail `db:migrate`.\n- **ADD COLUMN** is planned per engine: DuckDB rejects every inline constraint\n (add with `DEFAULT`, then `SET NOT NULL`, `CREATE UNIQUE INDEX`); SQLite\n rejects inline `UNIQUE` (separate `CREATE UNIQUE INDEX <table>_<col>_key`, the\n PostgreSQL constraint-index name, so the orphan sweep leaves it alone) and\n `NOT NULL` without a default on a populated table; PostgreSQL keeps constraints\n inline. DuckDB has no `ADD CONSTRAINT`, so the separate index is the only\n way to add uniqueness there; the bundled DuckDB 1.4.x resolves\n `ON CONFLICT (col)` through that index (the old #12684 limitation the DuckDB\n strategy's `requiresInlineUnique()` note describes no longer reproduces —\n the #2369 DuckDB test pins the upsert), older DuckDB builds may not. A required column with no default is enforced only on an empty table;\n on a populated one it is added nullable and the `NOT NULL` is reported as a\n manual follow-up on every engine.\n- **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual\n (comment SQL → `db:migrate` exit 1). The SQLite rebuild consumes\n only `type_upgrade` placeholders today; extending it to rewrite constraints\n would lift this.\n- Defaults compare through `canonicalizeDefault()`, which folds engine\n renderings (`'x'::text`, `CAST('t' AS BOOLEAN)`, `CURRENT_TIMESTAMP` vs\n `now()`) by manifest type; an unclassifiable rendering skips the comparison\n rather than risking a false positive that would churn every run. The\n round-trip test (create from each DDL strategy → compare → zero changes) in\n `src/migrations/__tests__/issue-2369-*.test.ts` guards this.\n\n### `schema.ddl` is a preview, not the table\n\n`SchemaDefinition.ddl` / `manifest.json` `schema.ddl` is the engine-neutral\nCREATE TABLE string from `SchemaGenerator.generateSQL()` with no engine: no\nindexes, no triggers, abstract `REAL`/`JSON`/`UUID`/`TIMESTAMP`. It is kept for\nbackward compatibility only. Everything that needs an executable table renders\n`columns` + `indexes` through `getDDLStrategy(engine)` — `db:migrate`\n(`migrations/orchestrate.ts`), `MigrationGenerator` (default\n`materializeStructuredSchema: true`; `false` is a deprecated opt-out),\n`SchemaAggregator`, and `createIsolatedTestDbFromManifest` in smrt-vitest, the\nlast two via `src/schema/manifest-schema.ts` (`collectManifestTables` /\n`renderCollectedManifestTable`). The cached string is merged in only for a\ntable whose contributors expose no structured columns (hand-authored\nmanifests); table constraints that exist only in the string are dropped with a\nwarning, as `db:migrate` drops them. Do not add a new consumer of the\nstring, and do not write a private CREATE INDEX renderer — the retired ones\ndropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out\n`PRIMARY KEY NOT NULL`: SQLite lets a bare non-INTEGER PRIMARY KEY hold NULL.\n\n### The merged table shape is registration-order independent (#2372)\n\n`getAllSchemas()` and `getAllSchemasAsDefinitions()` fold every class that\nshares a physical table — the whole STI hierarchy — into one shape. Both route\nthrough `buildMergedTableSchemas()`, which groups contributors by table and\nthen merges them in a **deterministic** order: the STI base first, then\nancestors before descendants, then by qualified name.\n\nThe first contributor supplies fallback columns, `idType`, conflict columns,\ncached DDL, and wins column conflicts. Keep base-first ordering even when a\nchild without manifest schema registers first.\n\nTwo invariants keep the two assembly paths agreeing:\n\n- `createBaseColumns()` mirrors what `generateSchemaFromManifest` /\n `generateSTISchemaFromManifest` emit for the same table, so a table built\n from runtime field metadata alone has the same NOT NULL/DEFAULT shape as one\n built from a manifest. Note `_meta_type` is `TEXT NOT NULL` with **no**\n default, matching the generator.\n- `fieldsToColumns()` reads `required`, `default`, and `description` from the\n top level *or* `_meta`. Registry fields normalize them into `_meta`\n (`manifest-field-merge.ts`), so reading only the top level silently dropped\n NOT NULL and DEFAULT for every registry-sourced field.\n\nSTI columns stay nullable regardless of the field's `required` flag\n(`fieldsToColumns(fields, { stiUnionColumns: true })`): the table holds the\nunion of all subtypes' fields, so a column only one subtype declares is never\npopulated on a sibling's row. Declared defaults are still emitted. This matches\n`generateSTISchemaFromManifest`, which sets `notNull: false` on every non-system\nSTI column.\n\nWhen adding a class-level input to the merged shape, take it from the seeding\ncontributor rather than \"whichever class arrives first\", and cover it with a\nchild-first/base-first equality test.\n\n### Default list ordering indexes\n\nAll four generators index `DEFAULT_LIST_ORDER_BY` (`created_at DESC, <pk> ASC`):\n`ensureDefaultListOrderingIndex()` emits `(tenant column, created_at)` when\nscoped, otherwise `(created_at)`. Resolve tenant columns by `referenceKind ===\n'tenantId'`, not spelling. The tenant-leading pair also serves the reference\nindex requirement.\n\nOnly an unqualified, non-JSON-path index with the same leading columns suppresses\nit. Append declared composites first, then default ordering, then reference\nindexes. `(tenant_id, created_at, status)` replaces the default pair;\n`(tenant_id, publish_date)` does not. Emit one plain index per STI table, since\nbase polymorphic reads lack `_meta_type` predicates.\n\nDo not add direction or PK columns by inference: `IndexDefinition` has no\nper-column directions, backward B-tree scans serve descending timestamps, and\nthe mixed-direction PK tie-break still needs incremental sorting within equal\ntimestamps.\n\n### One conflict-target rule, applied on every producer\n\n`save()` upserts on `ObjectRegistry.getConflictColumns()`; the schema must\ncarry exactly one unique index over those columns (or they must be the\nprimary key). Keep the derivation in `src/schema/conflict-target.ts` and let\nevery producer call it — the three manifest pipelines share\n`ManifestGenerator.applyGenerationPasses()` since #2360 because\n`ManifestBuilder` had silently skipped the report passes for months. When you\nadd a way for the key to vary (a new decorator option, a new class kind),\nthread it through `getConflictColumns()`, `normalizeConflictColumns()` and the\ngenerator's `resolveConflictTarget()` together, and extend the parity test's\n\"unique index == conflict target\" assertion; a key the runtime uses and the\nschema does not index is a hard PostgreSQL error (42P10) on the first save,\nand a key the schema indexes without the tenant column is the silent\ncross-tenant overwrite this rule exists for.\n\n### Every generated index name is length-guarded before it leaves a path (#2374)\n\nPostgreSQL truncates identifiers beyond 63 bytes; two generated names sharing\nthat prefix can make `CREATE INDEX IF NOT EXISTS` silently skip an index.\n\n`schema/index-utils.ts` owns the guard, and it splits by who owns the name:\n\n- **Generated index, trigger and PL/pgSQL function names** →\n `shortenIdentifier()`. Deterministic `<head>_<digest><suffix>`, digest taken\n over the **full** original so a shared prefix still yields distinct names, and\n a recognised suffix (`_idx`, `_unique_idx`, `_key`, `_pkey`) preserved.\n- **Hand-declared `@smrt({ indexes: [{ name }] })`** → `assertIdentifierFits()`,\n a hard error in `validateDeclaredIndex()`. Renaming what a developer wrote is\n worse than refusing it, and `SchemaComparer` matches indexes **by name**\n first, so a 70-byte declaration could never match the 63-byte index\n PostgreSQL stored and `db:migrate` would emit `add_index` forever.\n- **Table and column names** are not guarded: PostgreSQL truncates their\n declarations and references consistently. `smrt-users` tests intentionally\n long table names; collision risk remains with the author.\n\n`enforceIdentifierLimits()` is the single call site per path, placed **after**\n`ensureReferenceColumnIndexes()` — nothing may lengthen a name after it. Doing\nthe shortening at the end rather than at each `indexes.push()` is safe because\nthe digest covers the whole original name, so entries distinct before shortening\nstay distinct after; the helper still throws if two ever collide. The migrate\nleg's `withConflictIndex()` (`registry/schema-builder.ts`) and the PostgreSQL\ntrigger-function name call `shortenIdentifier()` directly, because they compose\na name outside the generator's index list. Note that an over-long *table* name\nstill yields in-limit, distinct *index* names, because the shortening runs over\nthe whole composed name.\n\nThe digest is FNV-1a, not `node:crypto`: `index-utils.ts` is re-exported from\n`schema/utils.ts`, which exists to keep Node built-ins out of browser bundles.\nIt only has to be *stable* — a shortened name that changed between releases\nwould make every deployment drop and recreate the index — so the parity and\nunit tests pin the literal output rather than recomputing it. Unpaired\nsurrogates are folded to U+FFFD before both counting and hashing, so the digest\nis taken over exactly the bytes the driver transmits.\n\nExisting databases migrate **by name swap, without a rebuild**: the live index\nstill carries the name PostgreSQL truncated it to, the manifest now carries the\nshortened one, and the differ claims it by signature (columns + uniqueness +\npredicate), emitting nothing — including under `includeDroppedIndexes`. See\n`migrations/__tests__/index-drift.test.ts` and the PostgreSQL lane test\n`schema/issue-2374-identifier-length-postgres.optional.test.ts`.\n\nOut of scope, deliberately: constraint names PostgreSQL invents for itself. A\nCTI table's inline `UNIQUE` produces an implicit `<table>_<column>_key`, which\ncan exceed 63 bytes even when the table and column each fit. SMRT never names\nit, and PostgreSQL disambiguates its own truncations by appending a counter\nrather than collapsing them, so there is no silent-collision hazard there.\n\n### The `_smrt_` prefix does not mean \"system table\" (#2376)\n\n`bootstrapSystemTables()` owns nine hand-written tables; ~25 more `_smrt_*`\ntables belong to `@smrt()` models and are created by `db:migrate` (feature\nflags, prompt overrides, subscription plans, report schedules, field policies,\njobs). Never classify by prefix — use `SYSTEM_TABLE_NAMES`\n(`schema/system-table-shapes.ts`, derived from the DDL parse) plus\n`FRAMEWORK_OPERATIONAL_TABLES` / `RETIRED_SYSTEM_TABLES` in `system/schema.ts`.\nThe change-feed writer skipped by prefix, so clients syncing those domain\ntables through `_changes` never saw an update.\n\nEditing `ALL_SYSTEM_TABLES` requires bumping `SMRT_SCHEMA_VERSION` *and*\nappending to `SMRT_SCHEMA_DDL_CHECKSUMS` — the version gates the DDL replay, so\nwithout a bump no existing database ever applies the change. A new **column**\nadditionally needs an `addColumnIfMissing()` entry in `system/compatibility.ts`\n(`CREATE TABLE IF NOT EXISTS` is a no-op on an existing table).\n`system-schema-evolution.test.ts` enforces both, and asserts a legacy database\nupgrades to exactly the shape a fresh install gets.\n\n`_smrt_jobs` / `_smrt_job_events` are dual-owned: `db:migrate` creates them,\nthe compatibility pass reshapes them. On a fresh install bootstrap runs first,\nso their pass is deferred — `ensureDeferredSystemTableCompatibility()` re-runs\nuntil the tables exist, then stamps a `<version>+deferred-compat` marker. It\nruns OUTSIDE the bootstrap lock and swallows its own failures: those statements\ntarget tables the framework does not own, and inside the PostgreSQL transaction\none failure would roll back system-table creation with it. Only\n`ensureBootstrapSystemTableCompatibility()` (the tables the DDL itself creates)\nbelongs inside the lock.\n\nReconciling `_smrt_jobs.task_id` uniqueness reads the live index catalog, which\nis implemented for PostgreSQL and SQLite only; DuckDB and the JSON adapter keep\nthe redundant compat index rather than risk dropping the one that enforces the\nupsert conflict target. When reading a PostgreSQL catalog array, cast it\n(`attname::text`) and parse both shapes — a driver with no parser registered for\nthe array OID returns the raw `{a,b}` literal, and reading that as \"no columns\"\nsilently inverts an index-existence decision.\n\n## Same-package referential integrity uses two matching rails\n\n`@foreignKey(Target)` emits a physical database constraint when the target is\nin the same package and applies the same action through `SmrtObject.delete()`\nin `src/cascade.ts`. A shared delete-action resolver keeps both paths aligned;\nthe established generated `ON UPDATE CASCADE` default remains unchanged:\n\n| Reference | Default when `onDelete` is absent |\n|---|---|\n| Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |\n| Polymorphic `(metaType, metaId)` association row | `CASCADE` |\n| Ordinary same-package reference | `NO ACTION` — deletion is refused while references remain |\n| Every `@tenantId()` field | Excluded from physical constraints and delete cascades |\n\nThe natural-key rule is what cleans junction rows up without any per-package\nannotation: a junction declares\n`@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so the\nrow is *identified* by the content and cannot outlive it. An ordinary child\n(`Order.customerId`) is keyed by `(slug, context)` and therefore defaults to\nimmediate `NO ACTION` unless it opts in explicitly.\n\n**`@tenantId()` is excluded even though it lands in `conflictColumns`.**\n#2360 leads every tenant-scoped class's *default* natural key with the\ntenant column, so without this exclusion, deleting one `Tenant` row would\nrecursively CASCADE through every tenant-scoped table in the schema that has\nnot declared its own `conflictColumns` — the overwhelming majority. The\ntenant column scopes ownership; it does not identify the row the way a\njunction's foreign key does. Detected via the `__tenancy.isTenantIdField`\nmarker on `FieldMeta` (`smrt-core` reads it structurally so it never depends\non `smrt-tenancy`). `@tenantId()` exposes no `onDelete` option today, so\nthis cannot currently be overridden per field.\n\n`@crossPackageRef()` remains runtime-only: it registers relationship loading\nand indexes but deliberately emits no physical constraint, avoiding circular\npackage DDL. Tenant markers follow the same non-constraint rule because a\ntenant is a scope, not an ownership edge.\n\nSame-package archival identifiers may explicitly use `@foreignKey(Target, {\nconstraint: false })`: preserve relationship loading and indexing, but omit\nphysical constraints, schema dependencies, and application cascade/preflight\nso the identifier survives parent deletion. Document the retention reason at\nthe field; ordinary references remain constrained.\n\nFor a same-package relationship whose semantics are portable but whose physical\nconstraint shape is not, `@foreignKey(Target, { constraint: { engines: [...] } })`\nis the public exception. The allowlist scopes physical DDL and dependency\nplanning only. Relationship metadata, UUID representation, derived indexes, and\nthe application delete rail remain canonical on every engine. Empty or unknown\nengine lists fail closed; unannotated unsupported DuckDB cycles and actions keep\ntheir actionable refusal.\n\nEvery schema creation entry point uses the same deterministic dependency\nplanner. Parents are created before children. SQLite keeps cycle constraints\ninline because it can create them safely. PostgreSQL creates mutually dependent\ntables first and adds their named constraints afterward. DuckDB refuses cycles,\nself-references, `CASCADE`, and `SET NULL` with an actionable error because its\ncurrent ALTER/constraint support cannot enforce those shapes safely.\n\nRollback drops children before parents, removes deferred PostgreSQL cycle\nconstraints first, and defers SQLite checks while dropping populated cycles.\nAggregation that filters a parent also removes a retained child's physical FK.\nPostgreSQL deferred constraint adds are idempotent. Generated `ON UPDATE\nCASCADE` remains the default; DuckDB/JSON must refuse unsupported actions\nrather than silently stripping them.\n\nFor existing tables, PostgreSQL checks the exact child table/column against the\nexact referenced table/column before adding a constraint as `NOT VALID` and\nthen validating it. The probe uses distinct child/parent aliases and, when both\nmanifest columns are UUIDs, bases its guarded casts on both live column types:\nmatching live types compare directly, while a legacy text side is shape-checked\nbefore casting. This keeps a self-reference or malformed legacy value from\ninvalidating the query. An orphan stops migration with detector SQL and an\nexecutable repair suggestion: nullable FKs are cleared, while required FKs\nrequire an explicit operator decision to reassign the reference or deliberately\nremove a child row after preserving its required data. A probe failure is\nsurfaced as a database/framework error, never misreported as orphan data.\nSQLite requires a deliberate table rebuild; DuckDB reports the unsupported ALTER\npath. Neither engine treats an unsupported constraint addition as a successful\nno-op.\n\n### Pre-R11 `text` ids converge to `uuid` before any FK statement (#2608)\n\nPostgreSQL FK columns must have matching physical types. Legacy text IDs may\nmeet newer native UUID references; neither SQLite (text UUID by design) nor\nDuckDB (no in-place type rewrite) emits this convergence.\n\n**The runtime guard fails closed.** `SchemaManager.ensurePostgresForeignKey()`\nreads both live column types and refuses to emit `ADD CONSTRAINT` when they\ndisagree, naming both columns, both live types, and the repair. It deliberately\nskips the orphan probe in that case: across mismatched types the probe answers\na question about casted values, not about the constraint being refused, and it\nhas to run again after the columns converge anyway.\n\n**The differ converges the columns.** `planUuidConvergence()`\n(`src/schema/uuid-convergence.ts`) groups every manifest relationship that\ndeclares UUID on both sides into connected components and converges a component\nonly when the live database already proves the target shape — at least one\nmember is native `uuid`. A component that is `text` on *every* side is the\ntolerated pre-R11 deployment and is left alone; its foreign keys are\ntype-compatible today, and the R11 uuid/text equivalence in\n`migrations/differ.ts` keeps it out of the column diff.\n\nConverge entire relationship components, including siblings and self-references;\nan unreferenced legacy text ID retains its UUID/text equivalence tolerance.\n\nThe planner never coerces data. Before emitting anything it probes each column\nit would rewrite for values that are not uuid-shaped (the same `~*` canonical\npattern the orphan probe uses) and refuses the whole component — with the count\nand a sample value — if any exist, if the probe cannot run, if a member carries\nsome third physical type, or if a live foreign key still constrains a column\nthat must change. `@happyvertical/sql` introspection does not expose live\nPostgreSQL constraint names, so SMRT cannot drop and re-add those constraints\nfor you: drop them deliberately, rerun the migration to converge, and let SMRT\nre-add the manifest constraints.\n\nRefusals are reported, not silent. Each one becomes a warning advisory with no\nexecutable SQL, so it reaches `unactionableChanges` / `hasManualDrift` and\n`db:status` shows **blocked: incompatible column types** instead of *pending*.\nThe same check runs per relationship in `compareForeignKeys`, so a foreign key\nwhose live types will still disagree after this run's conversions is reported\nblocked rather than emitted as pending DDL that cannot succeed.\n\nThe planner also inspects tables the manifest no longer declares. A live\nforeign key from an orphan table onto a column that must convert still blocks\n`ALTER COLUMN … TYPE`, so the differ introspects every existing table — not\nonly the manifest ones — whenever there is at least one conversion candidate,\nand reports the dependency instead of emitting DDL PostgreSQL would reject. An\nalready-converged database has no candidates and pays nothing.\n\nOrdering is a contract. Conversions carry `SchemaChange.phase =\n'pre_foreign_key'`, and the orchestrator emits them **before every CREATE TABLE\nand every foreign-key statement in the batch**. Both halves matter:\n`planForeignKeyCreation()` only defers the constraints inside a mutual cycle,\nso an acyclic new child table keeps its foreign key *inline in `CREATE TABLE`*\n— a brand-new `uuid` child pointing at a legacy `text` parent fails exactly\nlike an existing one, before the parent could be converted. Conversions only\never rewrite columns that already exist, so leading the batch is always safe. A\nlive `DEFAULT` on a converting column is dropped first (PostgreSQL refuses\n`ALTER COLUMN … TYPE` when the default cannot be cast); the ordinary default\ncomparison re-establishes the manifest default on the next run.\n\nThere are **two** batch builders and both order on that marker:\n`collectStatementsFromDiff()` in `migrations/orchestrate.ts` (used by\n`getPendingSchemaStatements` / `migrateSmrtSchemas`) and the tracker batch\n`db:migrate` assembles by hand in `@happyvertical/smrt-cli`\n(`commands/utilities.ts`). `partitionSchemaChanges()` carries\n`SchemaChange.phase` onto `MigrationAction.phase` so the CLI can partition the\nsame way, in both the applied batch and the `--dry-run` preview. If you add a\nthird consumer, order it the same way.\n\nConvergence entries carry the manifest column definition. Every `type_upgrade`\nconsumer reads `SchemaChange.column` — `partitionSchemaChanges()` in\n`@happyvertical/smrt-cli` skips an entry without one — so a conversion missing\nit would drop out of the `db:migrate` batch while `compareForeignKeys()` still\nassumed the converged type. Refused convergences carry the same column plus an\nadvisory and no SQL, and the CLI routes them to the report-only advisories\nrather than to manual interventions or the tracker.\n\nThe uuid wording is gated on the manifest. Both the runtime guard and the\nstatus planner reach their incompatible-type branch for *any* mismatched pair,\nnot only uuid/text. A `USING …::uuid` repair is suggested only when the\nmanifest declares UUID on both sides **and** a live side is actually `text`;\notherwise the diagnostic names the two live types and asks the operator to\nalign them deliberately.\n\nThe conversion is one-time and idempotent: once the column is native `uuid`,\nthe component is uniformly UUID and the planner emits nothing.\n\n### Application cascade invariants (`src/cascade.ts`)\n\n- Rebuild the registry-derived plan on every delete; manifests register lazily.\n Caching requires invalidation across every registration path.\n- Plan from `getResolvedQualifiedName()`. Every registered polymorphic\n association class participates, since its runtime target can be any class;\n `CascadePlan.isEmpty` requires no such class anywhere and no typed references.\n Only an empty plan skips the transaction.\n- Cascades are set-based: child hooks/interceptors and change-feed tombstones do\n not run. Only the explicitly deleted object runs its lifecycle. RESTRICT\n checks precede mutations; the parent DELETE and cascades share one transaction\n where supported, so deeper refusals roll back.\n- Derived `_smrt_embeddings` / `_smrt_contexts` cleanup matches IDs AND\n `ownerClassCandidates()` (qualified and simple STI member names), never IDs\n alone: unrelated text-ID classes can collide. Cleanup failures are logged,\n not raised, because these tables may not exist in older databases.\n- Never cascade append-only `_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals`,\n or dispatch logs; deleting change tombstones would break sync.\n\n### Retention (`src/system/retention.ts`)\n\n`runRetentionSweep(db, policy)` runs four built-ins in fixed order, then\n`registerRetentionTask()` contributions. A failed task records its result and\ncontinues; a missing table is `unavailable`. The `globalThis` registry avoids\nsplit registrations under duplicate core resolution; package tasks exist only\nafter importing the package. CLI prune optionally imports jobs/users.\n\nDefaults are opt-out: changes 30 days, AI usage 90 days, completed dispatch 30\ndays/failed dispatch 90 days, contexts by `expires_at`. `smrt.configure({\nretention })` tunes built-ins; contributed tasks own their defaults/options.\nJobs defaults are 7 days terminal, 30 failed, 30 events via\n`registerJobRetentionTasks()` or runner `retention.jobs`; expired credentials\nhave no extra window. Disable a table/task with `false` or the whole policy with\n`enabled: false`; CLI `--skip` and runner configuration expose these controls.\nTask names use package prefixes (`jobs-records`, `users-sessions`).\n\nCore does not schedule sweeps. TaskRunner runs every six hours, first one\ninterval after start; `retention: false` opts out. `smrt db:prune` supports cron.\nEvery prune counts then deletes using the same predicate; `rowCount` is not\nportable. The two statements deliberately are not transactional, so counts are\napproximate under concurrency. Overlapping change/AI-usage bounds exclude rows\nalready counted, including dry runs.\n\nRetention indexes belong in system DDL and its versioned replay:\n`_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`, and dispatch\n`(status, processed_at)` / `(status, updated_at)`. Jobs `(status, completed_at)`\nbelongs in `ensureJobsSystemTableCompatibility()` on each collection initialize,\nsince decorated jobs tables do not exist at bootstrap.\n\nExpiry remains prune-side for object/collection `recall()`/`recallAll()`;\n`LearningMemory` separately filters it at read time.\n\n## Supported generation surfaces\n\nThe four entry points above are the supported generator paths. The unused AST\n`generateSchema(objectDef)`, `smrt:schema` / `@happyvertical/smrt-virt-schema`\nvirtual modules, and project-specific `SchemaOverrideSystem` were removed.\n\nKeep published `SchemaDefinition.triggers`, `TriggerDefinition`, and the DDL\nstrategies' trigger renderers: they support hand-authored new-table schemas.\nGenerated `@smrt()` schemas always emit `triggers: []`; no decorator populates\nit, `save()` maintains `updated_at`, and the differ never retrofits triggers.\nAdding live trigger generation requires a migration rollout design.\n`_smrt_signals` and database-persisted registry APIs are retired; see\n`RETIRED_SYSTEM_TABLES` in `src/system/schema.ts`.\n\n## PostgreSQL migration execution\n\n`MigrationTracker.applyAll({ atomic: true })` sets local lock/statement timeouts\nbefore any DDL. `postgresSafe: true` commits non-index DDL atomically, then runs\nindexes CONCURRENTLY on `db.acquireSession()` so settings and DDL share a\nconnection. This mode is not atomic. Unfinished indexes are `failed`, not\n`running`; `[smrt: concurrent-index phase 1 committed]` in `error_message`\nallows reruns to resume index work without replaying committed DDL. Inspect\n`pg_index.indisvalid` and drop INVALID indexes before rebuild (`pg_indexes`\nalone cannot detect them). Operational commands: `packages/cli/AGENTS.md`.\n"
|
|
1013
1015
|
},
|
|
1014
1016
|
{
|
|
1015
|
-
"path": "agents/
|
|
1016
|
-
"module": "
|
|
1017
|
-
"content": "#
|
|
1017
|
+
"path": "agents/change-feed.md",
|
|
1018
|
+
"module": "change-feed",
|
|
1019
|
+
"content": "# smrt-core/change feed\n\nModule semantics for `src/change-feed.ts`. 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## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- Staged appends (PostgreSQL, #2649): `MAX+1` costs a *wait* — the loser of a primary-key race waits for the winner's whole transaction — so an append inside a caller transaction that already wrote rows would let a long write transaction and an ordinary concurrent request form a real lock cycle (`40P01`, found downstream in willgriffin/willgriffin.dev#457). `_smrt_append_change` therefore checks `pg_current_xact_id_if_assigned()` (PostgreSQL 13+): when the caller already has a transaction id it stages the entry in `_smrt_changes_pending` (identity key, conflicts with nothing, never waits, still rolled back with the caller) and `appendChange()` returns **`null`** instead of a sequence. `_smrt_drain_changes()` moves *committed* staged rows into `_smrt_changes` with contiguous `MAX(seq)+row_number()` sequences under a **try-only** advisory lock, so the drain never waits either. Draining is driven from JavaScript, never inside the append helper: an entry sequenced invisibly server-side would get no live `_events` signal while the append's own signal carried a higher sequence, and a subscriber resuming from that `Last-Event-ID` would skip it permanently. `drainChangeFeed(db)` is called best-effort by `getChangesSince()`, by `pruneChangeFeed()`, and by the append path itself (throttled to one drain per 250 ms per database, bypassed immediately after this process staged an append). The append path issues ONE drain statement (a single bounded pass) and nothing else — the caller may own the surrounding transaction, and a second statement there could fail and abort it behind the feed's own error-swallowing (#2026); the helper's whole body, preflight included, sits inside its exception boundary for the same reason. It still settles that drain's signals in order without spending a statement: a drain that allocated anything assigns a transaction id, so an append that then takes the DIRECT path proves the drain committed, and its signals publish ahead of the append's own; a deferred append proves nothing and queues them. Signals for drained entries publish only after a follow-up probe proves the drain committed, so an uncommitted drain can never advertise a sequence a rollback releases for reuse. `getChangesSince()` additionally holds back everything the handle's own drains allocated while a transaction id is still assigned at read time (a per-handle watermark, so later reads in the same transaction keep holding back), and `bootstrapSystemTables()` refreshes the helpers before its schema-version fast return so an already-stamped database does not keep the deadlocking function. Consequences: the cursor guarantee is unchanged (one `MAX+1` writer at a time, only over committed work), but a staged entry becomes visible one drain after its transaction commits, its log position is drain order rather than statement order, and it publishes its live `_events` signal at drain time (post-commit — the old pre-commit signal could describe a rolled-back write). SQLite/DuckDB keep the direct insert unchanged. Residual: an append issued as a transaction's *first* statement still allocates inline — it holds no row locks then, so it cannot close a cycle, but it can make other direct appenders wait for that commit; issue explicit `bumpChangeFeed()` calls after the write or outside the transaction. A deployment that writes only through transactions and reads the feed from a connection that cannot write must schedule `drainChangeFeed()` on a writable one.\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table **plus that table's staged-but-undrained count**, so a staged write still moves the ETag and cannot false-304 a client; the sum is monotonic because draining `n` staged rows raises the table's `MAX(seq)` by at least `n`, and both terms are read in ONE statement — separate reads let a drain be counted twice, minting a version a later write re-mints; replica-stable, with no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable. The age bound is a **prefix** bound — everything below the oldest entry still inside the window — because `created_at` and `seq` are not co-monotonic (writer clocks skew, and a staged entry carries its stage-time stamp into a later-assigned sequence); deleting by timestamp alone could punch a hole in the middle of the retained run, where `since < floor - 1` cannot see it and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n"
|
|
1018
1020
|
},
|
|
1019
1021
|
{
|
|
1020
|
-
"path": "agents/
|
|
1021
|
-
"module": "
|
|
1022
|
-
"content": "
|
|
1022
|
+
"path": "agents/change-signals.md",
|
|
1023
|
+
"module": "change-signals",
|
|
1024
|
+
"content": "# smrt-core/change signals / live events\n\nModule semantics for `src/change-signals.ts` + the generated `_events` SSE route. 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## Live Events / Change Signals (#1763, server half)\n\nThe push companion to the change feed (`src/change-signals.ts` + the generated `_events` SSE route) — the server half of live cache invalidation (PRD #1755). The client subscriber (two-client/reconnect/polling-fallback ACs) is a separate later slice.\n\n- **Change-signal bus** (`src/change-signals.ts`): every framework save/delete that appends a durable feed row also publishes a coarse `ChangeSignal` `{ table, operation, rowId, tenantId, seq }` — **never a row payload** (authorization stays on the read path). Structurally mirrors the collection cache's notify/listen path. `subscribeToChangeSignals(db, listener) → unsubscribe`; `publishChangeSignal`/`broadcastChangeSignal`/the listener loop stay internal. `appendChange` now returns the allocated `seq` (was `void`); the signal carries it as a coarse resume cursor (`bumpChangeFeed` ignores the return). The publish runs only after the append SUCCEEDS (no signal without a durable feed row) and in its own log-and-swallow try/catch, so a signal problem never fails the user's write. `_smrt_*` writes never signal (the writer skips them). Delivery is synchronous per-listener with per-listener try/catch (one throwing SSE controller never blocks others); no per-subscriber queue — backpressure rides the platform `ReadableStream`.\n- **In-process + cross-replica**: locally-published and peer-received signals go through the SAME `deliverLocally` path. Cross-replica fan-out rides the db adapter's optional notification capability (`db.notifications`, a NEW `smrt_change_signals` channel distinct from the cache channel) with echo-avoidance by `PROCESS_ID`. No capability → in-process only, warn-once, **never an error, never blocks the write** (subscribers on other replicas fall back to cursor polling).\n- **Generated `_events` SSE route**: REST (`GET {basePath}/_events`, requires `authMiddleware`, otherwise 401 — fail-closed, per-model `api.public` does NOT apply; 405 non-GET; 503 no db or subscriber capacity reached) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`; cap via `sveltekit.eventsRoute.maxSubscribers`, where `0` means unlimited). Tenant scope is captured ONCE at connection open (`resolveDispatchTenantScope`) and filtered server-side per signal via `signalVisibleToTenant` (same rule as `getChangesSince`'s tenant filter) before any byte hits the wire — delivery runs outside any tenant ALS context, so it must use the captured value. The stream lifecycle lives in `buildChangeEventStream(db, { cursor, tenantScope, heartbeatMs?, manifestHash? })` (exported; the SvelteKit route imports it so it stays thin): subscribe-before-catch-up (closes the gap window; overlap is deduped by the SSE `id:`/seq client-side), `retry: 3000`, optional connection-open `event: manifest` carrying `{ manifestHash }` for live contract detection, cursor catch-up via `getChangesSince` filtered by the CAPTURED scope (not re-resolved from ALS, so it matches the live-signal filter exactly and can't replay another tenant's rows) (`Last-Event-ID` header beats `?since=`; default = live-forward only; `resyncRequired` → `event: resync`), heartbeat (`DEFAULT_EVENTS_HEARTBEAT_MS` = 15s), and `cancel()` teardown (clears heartbeat + unsubscribes). SSE change frame: `id: <seq>\\nevent: change\\ndata: {table,operation,rowId,tenantId}\\n\\n` — seq is ONLY in the `id:` line, never the data JSON. Subscriber cap default is `DEFAULT_EVENTS_MAX_SUBSCRIBERS` = 1000; over-cap connections return retryable 503 + `Retry-After`, and existing subscribers are unaffected. **Cross-origin is opt-in and fail-closed (#1861)**: same-origin only by default. REST wraps `_events` in its CORS layer — set `enableCors` + an explicit `allowedOrigins` allowlist + `allowCredentials: true` and an allow-listed browser can subscribe with a credentialed `EventSource` (`withCredentials: true`); the response echoes the specific `Origin` (never `*`) plus `Access-Control-Allow-Credentials: true`. SvelteKit mirrors this via `sveltekit.eventsRoute.allowedOrigins` + `allowCredentials` (the generated route bakes the allowlist into a `Set`, echoes only a member origin, and answers a credentialed `OPTIONS` preflight). CORS never authorizes — the fail-closed auth guard and captured tenant scope are unchanged, so the read posture holds identically across origins; it only lets an allow-listed browser's cookies reach the guard. Client disconnect through the Node `createServer` bridge now cancels the response reader (was a teardown leak) so `cancel()` fires and the subscription is released.\n- **Known gaps** (documented in the module): raw-SQL writes don't signal (same gap as the feed); live signals for caller-managed-transaction writes are best-effort (the append + signal fire pre-commit, so a rolled-back write may emit a signal and its freed seq is later reused) — the autocommit default path is exact, and clients reconcile via full catch-up/resync (inherits the change feed's transaction caveat).\n"
|
|
1023
1025
|
},
|
|
1024
1026
|
{
|
|
1025
|
-
"path": "agents/
|
|
1026
|
-
"module": "
|
|
1027
|
-
"content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Query bounds (#2367)\n\n`src/query-bounds.ts` is the single parser every generated read surface uses for\n`limit`/`offset`. A bound is a non-negative integer or it is a client error:\nmalformed input raises a 400-typed `QueryBoundsError` (a `ValidationError`, so\n`withRetry` never retries it and `normalizeTypedHttpError` renders it as a\nstructured 400) instead of reaching the driver as `LIMIT NaN`. Oversized pages\nare **clamped** to `MAX_LIST_LIMIT` (1000) rather than rejected, and an explicit\n`0` means zero rows — it is not folded into `DEFAULT_LIST_LIMIT` (50).\n\n- `collection.get()` / `findOne()` / `findById()` emit `LIMIT 1`.\n- `collection.list()` validates `limit`/`offset` but applies **no implicit\n default**: it is the framework's bulk-read primitive and relationship,\n junction, hierarchy and `listByIds()` callers all expect every matching row, so\n a framework-wide default would truncate correct queries. Applications opt in\n per collection with `defaultListLimit` / `maxListLimit` (validated at\n construction). The generated surfaces enforce the ceiling on their own\n untrusted input regardless.\n- `orderBy` runs the same rail as `where` (#1540) and `select` (#1902): terms are\n checked against the field whitelist and refused for `@field({ sensitive: true })`\n and `@field({ readPermission })` columns — ordering is a comparison, and\n `?orderBy=api_secret&limit=1` is an oracle over a column the request may neither\n filter on nor project — and for fields that are registered but **not\n column-backed** — `oneToMany`/`manyToMany`/`meta`/`transient`, plus the\n `id`/`slug`/`context` system columns on a custom-primary-key class, which the\n schema generator omits — all of which otherwise reach the driver as\n `no such column` and surface as a 500. The whitelist is skipped only for\n manifest-less inline test classes (#869), exactly as `where` skips it. `where`\n still whitelists those omitted system columns; that is a pre-existing gap of\n the same family, not closed here because `where: { id }` is on internal\n hydration paths.\n- Every generated list surface (REST, SvelteKit, MCP, and the emitted stdio MCP\n runtime) pages with `ORDER BY created_at DESC, <pk> ASC` unless the caller\n supplies `orderBy` — `LIMIT`/`OFFSET` with no ordering is not pagination, and\n `created_at` alone still ties. `<pk>` follows a declared\n `@field({ primaryKey: true })` (read from `_meta.primaryKey` in manifests)\n because custom-primary-key classes have no synthetic `id` column. The stdio\n runtime gets the per-object ordering baked in via `RuntimeOptions.listOrderBy`;\n it cannot resolve a primary key on its own. Index: #2363.\n- `listByIds()` chunks its `IN` list at `IN_LIST_CHUNK_SIZE` (900), like the\n relationship/junction/hierarchy loaders.\n\nKeyset pagination is deliberately out of scope.\n"
|
|
1027
|
+
"path": "agents/generators.md",
|
|
1028
|
+
"module": "generators",
|
|
1029
|
+
"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| CLI | `src/generators/cli.ts` | `objectname:action` admin commands — writable allowlist, exhaustive-include, `--from-file`, fail-closed tenant context |\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).\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` is a warning. A cross-file duplicate\narrives as a *diagnostic* rather than two entries — the scanner's merge already\ndropped the loser — so that diagnostic maps to the duplicate error rather than\nthe not-static warning; otherwise the error would be unreachable for the case it\nexists to catch.\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"
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
"path": "agents/build-knowledge.md",
|
|
1033
|
+
"module": "build-knowledge",
|
|
1034
|
+
"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## Lightweight discovery\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 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\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```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"
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
"path": "agents/memory.md",
|
|
1038
|
+
"module": "memory",
|
|
1039
|
+
"content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Object memory and semantic search\n\nContext memory (`remember`, `recall`, `recallAll`, `forget`, `forgetScope`) is\nstored in `_smrt_contexts`, keyed by owner, scope, key, and version. Values have\na 0–1 confidence and optional expiry metadata; `recall()` does not filter\nexpired rows. Ancestor fallback is opt-in (`includeAncestors: true`) and walks\n`a/b/c → a/b → a → global`. `LearningMemory` owns outcome counters and expiry\nfiltering; object/collection recall does not update them.\n\nSemantic search uses `_smrt_embeddings` and cosine ranking over fields declared\nby `@smrt({ embeddings })`, with native pgvector/HNSW or an in-memory fallback.\nResults hydrate through `list({ 'id in': … })`, so normal tenant isolation still\napplies. Keep injected search behind the `SmrtCollection.semanticSearch`\nboundary.\n\n`LearningMemory.capture()` reinforces successes and decays failures while\nupdating outcome counters. Its tenant-isolated `recall()` applies confidence,\nexpiry, time-decay, and hierarchical-scope filters and refreshes `last_used_at`.\n"
|
|
1028
1040
|
}
|
|
1029
1041
|
]
|
|
1030
1042
|
}
|