@happyvertical/smrt-core 0.45.1 → 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 CHANGED
@@ -1,299 +1,91 @@
1
1
  # @happyvertical/smrt-core
2
2
 
3
- ORM, code generation, AI integration, and the DispatchBus. Everything else builds on this.
4
-
5
- Key surfaces are `SmrtObject`, `SmrtCollection`, `ObjectRegistry`,
6
- `DispatchBus`, `GlobalInterceptors`, and `LearningMemory`; this file documents
7
- their invariants and source locations, and the module docs below cover the
8
- per-subsystem semantics.
3
+ Foundation ORM, registry, schema/code generation, AI integration, and DispatchBus.
4
+ Read the module for the subsystem being edited; root AGENTS covers shared model
5
+ and repository rules.
9
6
 
10
7
  ## Modules
11
8
 
12
- Subsystem semantics live in sibling module docs — read the one for the
13
- subsystem you are editing. This file keeps what holds across all of them.
14
-
15
- | Module | Scope | Module doc |
9
+ | Source | Scope | Module doc |
16
10
  |---|---|---|
17
- | `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) |
18
- | `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) |
19
- | `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) |
20
- | `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) |
21
- | `src/data-query.ts` | canonical bounded data-query normalizer and transport-neutral envelope (#2444) | [agents/data-query.md](agents/data-query.md) |
22
- | `src/collection.ts` | bounded collection reads, projections, latest-related hydration, facets, counts, and read plans | [agents/collection-reads.md](agents/collection-reads.md) |
23
-
24
- ## SmrtObject Lifecycle
25
-
26
- `constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`
27
-
28
- - `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided
29
- - `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)
30
- - Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws
31
- `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or
32
- delete to an earlier snapshot. Remote guarded deletes bind the same predicate
33
- into the final `DELETE`; embedded adapters compare inside the shared write queue
34
- before cascading. That queue serializes same-process saves, deletes, and full
35
- `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this
36
- public CAS ordering contract. PostgreSQL predicate:
37
- [agents/revision-guard.md](agents/revision-guard.md).
38
- - Native DuckDB UUID columns are hydrated as canonical strings before model
39
- initialization, natural-key lookup, and embedded revision claims. Exact
40
- natural-key probes retain the interceptor-authorized filter when
41
- canonicalizing a wrapped identity. Custom embedded-CAS paths that consume
42
- persisted rows must use `getCanonicalPersistedRow()` so UUID identities are
43
- cast in the same coherent read before reuse.
44
- - `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)
45
- - `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`
46
- - `getSlug()`: auto-generates from name → title → label → id
47
- - `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)
48
-
49
- ## LearningMemory (#1886)
50
-
51
- `LearningMemory` provides tenant-isolated, confidence-scored recall over
52
- `_smrt_contexts` plus optional injected semantic search. `capture()` reinforces
53
- successes and decays failures while updating outcome counters; `recall()`
54
- applies confidence, expiry, time-decay, and hierarchical-scope filters and
55
- refreshes `last_used_at`. Detailed persistence and search semantics are in
56
- [agents/memory.md](agents/memory.md). Keep semantic search behind the
57
- `SmrtCollection.semanticSearch`-compatible injection boundary.
58
-
59
- ## SmrtCollection Query
60
-
61
- ```typescript
62
- await collection.list({
63
- where: { status: 'active', 'price >': 10 },
64
- limit: 50, offset: 0, orderBy: 'created_at DESC'
65
- });
66
- ```
67
-
68
- Projection, latest-related, facets, counts, and bounded read plans are
69
- documented in [agents/collection-reads.md](agents/collection-reads.md).
70
-
71
- `list()` and `query()` hydrate model instances serially in result order because
72
- an `initialize()` hook may query through the same transaction-bound PostgreSQL
73
- client. Keep this serialization invariant; use `select` when callers need plain
74
- rows without model hydration.
75
-
76
- Native DuckDB model hydration casts declared UUID columns to `VARCHAR` in the
77
- read query because its JavaScript binding otherwise returns lossy HUGEINT
78
- wrapper objects. Explicit projections apply the same cast for selected UUID
79
- fields so bounded query envelopes preserve canonical row and relationship ids.
80
- For STI child columns, raw `query()` SELECTs, and latest-related projections,
81
- the read path describes the output types without evaluating the query, then
82
- performs one data-bearing SELECT with UUID result columns cast to `VARCHAR`;
83
- mutation statements are never reinterpreted or replayed.
84
-
85
- **WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.
86
- Arrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`
87
- renders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.
88
-
89
- This list is the set `@happyvertical/sql`'s `buildWhere` can execute, and
90
- `convertWhereKeys` accepts nothing outside it — an operator accepted here but
91
- unknown there fails inside the query builder, after the API said the query was
92
- valid (#2276). Two entries were removed for that reason and now reject at the
93
- API boundary: `contains` (never existed in the SQL layer; use `like` with
94
- explicit wildcards) and dot-notation JSON paths such as `metadata.userId` (never
95
- rewritten into an extraction expression, so they reached SQL as qualified column
96
- references). Re-adding either requires the query builder to support it first;
97
- `src/__tests__/issue-2276-where-contract.test.ts` executes every accepted
98
- operator against a database to keep the two in step.
99
-
100
- STI 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).
101
-
102
- ## Canonical Bounded Data Queries (#2444)
103
-
104
- The normalizers and fingerprint are the trust boundary for the
105
- transport-neutral query envelope; full bounds, schema, and output rules live in
106
- [agents/data-query.md](agents/data-query.md). Adapters own tenant/principal
107
- access and query execution.
108
-
109
- ## Object Memory & Semantic Search
110
-
111
- Context memory and semantic search are persistence primitives inherited by
112
- `SmrtObject`/`SmrtCollection`; their storage, scope, expiry, and tenant
113
- invariants are in [agents/memory.md](agents/memory.md).
114
-
115
- ## @smrt() Decorator Options
116
-
117
- Key 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).
118
-
119
- Registration sets `SMRT_TABLE_NAME` static property (survives minification).
120
-
121
- ## @field() UI hints (#2046)
122
-
123
- `@field({ ui: { basic, group, order, locked } })` — a static, presentation-only
124
- seed for the field-policy rail (epic #2045). Carried in the manifest under the
125
- field's `_meta.ui` (never a top-level `FieldDefinition` key), readable at
126
- runtime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with
127
- `description` into generated web-collection definitions and browser MCP tool
128
- schemas. No schema/persistence/security effect — `sensitive`/`readPermission`
129
- stay the security rail, and `sensitive`/`transient` fields never emit to the
130
- client at all.
131
-
132
- ## Domain Knowledge Artifacts
11
+ | `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) |
12
+ | `src/revision-guard.ts` | Guarded writes and PostgreSQL revision precision | [agents/revision-guard.md](agents/revision-guard.md) |
13
+ | `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |
14
+ | `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |
15
+ | `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |
16
+ | `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |
17
+ | `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |
18
+ | `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |
19
+ | `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |
20
+ | `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |
21
+ | `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |
22
+
23
+ ## Cross-module invariants
24
+
25
+ - `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.
26
+
27
+ - Production DDL uses manifest generators; `getTestDatabase()` uses registry
28
+ generators. Keep columns, indexes, FK actions, and runtime conflict targets in
29
+ parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate
30
+ needs its index or an explicit reason none is needed.
31
+ - Tenant scoping covers every read and every unique/conflict key. Explicit
32
+ conflict columns are not rewritten; their author must include tenant scope.
33
+ - Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.
34
+ Preserve revision compare-and-swap ordering through public `save()`,
35
+ `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete
36
+ `withTransaction()` callbacks share a process-local write queue.
37
+ - Collection model hydration is serial in result order: initialization may query
38
+ the same transaction-bound PostgreSQL client. Use projections for plain rows.
39
+ - Native DuckDB UUIDs must be cast coherently on read before identity reuse;
40
+ custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay
41
+ a mutation to discover result types.
42
+ - `withDatabase(db, callback)` restores only database bindings (including public
43
+ `options.db`); `withTransaction(callback)` also restores identity/revision
44
+ metadata after rollback. Do not use a bound instance concurrently.
45
+ - `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;
46
+ call it on a base PostgreSQL connection before caller-owned transactions.
47
+ Bootstrap uses an advisory lock. Application tables still require migrations;
48
+ runtime table verification checks existence only.
49
+ - Manifest generation fails closed on scanner errors, including unresolved
50
+ decorator spreads. Never emit partial, default-open registration.
51
+ - Generated registration repairs bundled class identity using the exact imported
52
+ constructor, explicit package, and isolated one-object manifest. Never infer
53
+ ownership from paths, simple names, or table names; packages can share names.
54
+ Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.
133
55
 
134
- `smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:
135
-
136
- - local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`
137
- - package build: `dist/manifest.json` and `dist/smrt-knowledge.json`
138
-
139
- Keep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic
140
- agent contract for downstream review and architecture tools.
141
-
142
- The schema-version-1 object projection is additive and high-signal: it retains
143
- normalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,
144
- method signatures, and field defaults/constraints/readonly/transient flags.
145
- Sensitive fields are removed before both `fields` and `relationships` are
146
- derived, including legacy flags stored under `_meta`; matching field and
147
- snake-case column names are also removed from projected conflict columns, and a
148
- sensitive custom tenant field is omitted while retaining scope and mode.
149
- Generated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;
150
- the optional marker keeps schema version 1 additive while letting readers
151
- identify older artifacts that require raw-manifest corroboration.
152
-
153
- Config precedence for knowledge is defaults → top-level `knowledge` in
154
- `smrt.config.ts` → `packages[packageName].knowledge` → plugin option →
155
- object-level `@smrt({ knowledge })`.
156
-
157
- Object-level `knowledge: false` excludes an object from authored context only;
158
- it must not change runtime manifest registration. Use
159
- `knowledge: { tags, summary, risks }` for review-sensitive domain objects.
160
-
161
- HTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is
162
- true, generated SvelteKit routes must stay GET-only and guarded by dev mode or
163
- admin auth.
164
-
165
- ## DispatchBus
166
-
167
- - `emit(signalType, payload, metadata)` → creates persistent Dispatch record
168
- - `on(pattern, handler)` → in-memory handler (immediate)
169
- - `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)
170
- - `process(subscriberName, handler)` → process pending dispatches
171
- - Wildcards: `campaign.*` matches `campaign.completed` (single segment only)
172
- - Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`
173
- - Status: `pending → processing → completed` (or `failed`)
174
-
175
- ## Single Table Inheritance (STI)
176
-
177
- - Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table
178
- - Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)
179
- - Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)
180
- - Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically
181
- - Validation: fail-fast on save if `_meta_type` missing or mismatched
182
-
183
- ## Child Accessors (R10)
184
-
185
- `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:
186
-
187
- - **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.
188
- - **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.
189
-
190
- When 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).
191
-
192
- ## Vite Plugin
56
+ ## Gotchas
193
57
 
194
- ```typescript
195
- // vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)
196
- export default defineConfig({
197
- oxc: {
198
- decorator: {
199
- legacy: true,
200
- emitDecoratorMetadata: true,
201
- },
202
- },
203
- });
58
+ - Optional filesystem support stays lazy: use `createFilesystemAdapter()` in
59
+ `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps
60
+ import `@happyvertical/smrt-core/filesystem` at startup. Use
61
+ `importOptionalDependency()` for similarly heavy optional dependencies.
62
+ - Database retries are transient-only, four attempts total for `get`/`upsert`.
63
+ Use `src/db-errors.ts` classifiers through the cause chain, never message
64
+ matching (SDK driver text can live in `context.originalError`). Constraints,
65
+ bad input, missing tables, and aborted PostgreSQL transactions fail immediately.
66
+ Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes
67
+ `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.
68
+ - Property initializers precede option values; options win. Arrays/objects are
69
+ shallow-cloned. Collection creation caches fields; table verification caches
70
+ by DB URL and table. Preserve these scopes when changing initialization.
71
+ - The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild
72
+ core after editing those sources before testing consumer manifest generation.
73
+ Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.
74
+
75
+ ## Validation
76
+
77
+ Run focused tests first, then applicable package checks:
78
+
79
+ ```bash
80
+ pnpm --filter @happyvertical/smrt-core test
81
+ pnpm --filter @happyvertical/smrt-core typecheck
82
+ pnpm --filter @happyvertical/smrt-core build
83
+ pnpm --filter @happyvertical/smrt-core test:postgres
84
+ pnpm check:agents-chain
85
+ pnpm smrt dev:knowledge-check
204
86
  ```
205
87
 
206
- Under Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`
207
- recipe (or tsconfig `experimentalDecorators` reached through SvelteKit's
208
- `extends "./.svelte-kit/tsconfig.json"` chain), so that recipe throws
209
- `SyntaxError: Invalid or unexpected token` on the first SSR request. Configure
210
- decorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need
211
- the legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,
212
- emitDecoratorMetadata: true`.
213
-
214
- For independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept
215
- the same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The
216
- schema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the
217
- merged project/dependency manifest, portable source paths, and source-file
218
- digests; each plugin selects its own view. Reuse mode fails closed on
219
- byte/provenance/path/content drift, skips scans and manifest writes, and still
220
- generates routes, types, registration, and virtual modules. Omit it for normal
221
- local development and watch mode.
222
-
223
- ## Schema paths (#2382)
224
-
225
- `ensureSystemTables(db, typeHint?)` is the public, idempotent provisioning
226
- boundary for framework-owned `_smrt_*` tables. Call it on a base PostgreSQL
227
- connection before opening a caller-owned transaction; its advisory-locked
228
- bootstrap prevents missing-table probes from poisoning that transaction.
229
-
230
- Production DDL comes from the **manifest** paths
231
- (`generateSTISchemaFromManifest`/`generateCTISchemaFromManifest`, selected in
232
- `src/scanner/manifest-generator.ts` → registered `schema` → `db:migrate`). The
233
- **registry** paths feed `getTestDatabase()`. Manifest and registry schemas must
234
- agree on same-package foreign keys as well as columns and indexes:
235
- `@foreignKey` emits a named physical constraint, while `@crossPackageRef` and
236
- `@tenantId` remain indexed runtime relationships without physical constraints.
237
- Natural-key references default to `CASCADE`; ordinary references default to
238
- immediate `NO ACTION`, matching `SmrtObject.delete()`.
239
-
240
- Same-package archival/audit identifiers that intentionally outlive their
241
- parent may use `@foreignKey(Target, { constraint: false })`. This explicit
242
- exception retains relationship loading, indexing, and application-side delete
243
- metadata while omitting the physical constraint, schema dependency, and
244
- app-side cascade/preflight action so the stored identifier survives deletion;
245
- document the retention reason at the field, and keep ordinary same-package
246
- relationships constrained.
247
-
248
- When a relationship is valid on every engine but a particular database cannot
249
- faithfully enforce its physical shape, use the public, explicit allowlist
250
- `@foreignKey(Target, { constraint: { engines: ['postgres', 'sqlite'] } })`.
251
- Only physical DDL and schema dependency planning are engine-scoped; native UUID
252
- storage, relationship loading, indexes, and application-side delete enforcement
253
- remain active on every engine. Empty or unknown allowlists fail closed. Do not
254
- use this option to hide an otherwise invalid schema.
255
-
256
- - Change column/index emission on every shipping path, proven by the path-parity
257
- 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.
258
- - Every new query predicate ships with its index, or a reason it doesn't.
259
- - Creation is dependency-planned on every entry point. PostgreSQL defers mutual
260
- cycle constraints until both tables exist; SQLite keeps cycles inline;
261
- DuckDB refuses unsupported cycles/actions unless the field has an explicit
262
- physical-constraint engine allowlist rather than silently omitting them.
263
- In particular, generated same-package constraints retain the compatibility
264
- default `ON UPDATE CASCADE`; DuckDB/JSON cannot enforce that action and must
265
- return an actionable refusal instead of stripping the clause.
266
- PostgreSQL deferred adds are idempotent and probe the exact child/parent
267
- columns for orphans before `NOT VALID` + validation. Rollback drops children
268
- before parents, removes deferred PostgreSQL cycle constraints first, and
269
- defers SQLite checks while dropping populated cycles. Schema aggregation that
270
- deliberately filters a parent also removes the retained child's physical FK.
271
- - Numeric types, uuid casts, conflict targets, timestamps, migrations: run the
272
- `test:postgres` lane — SQLite affinity accepts what PostgreSQL rejects.
273
- - Read `dist/manifest.json`/regenerated schemas for what a decorator produced;
274
- count across all packages instead of sampling.
275
- - Tenant scoping is whole-path: every unique constraint and conflict target on a
276
- tenant-scoped table carries the tenant column, and every read path — not only
277
- `list()` — is interceptor-aware.
278
- - Rolling indexes out is part of the change: a bulk `CREATE INDEX` batch needs
279
- the bounded, concurrent migrate path (#2362, Gotchas), or it takes production
280
- down on deploy.
281
-
282
- ## Gotchas
283
-
284
- - **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.
285
- - **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.
286
- - **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`
287
- - **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)
288
- - **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
289
- - **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`.
290
- - **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()`.
291
- - **Field caching**: `_cachedFields` populated during `Collection.create()` — eliminates async `getFields()` per query
292
- - **Smart cloning**: arrays/objects shallow-cloned in property init to prevent aliasing (Issue #22)
293
- - **Table verification cache**: `isTableVerified(dbUrl, tableName)` avoids redundant `tableExists()` calls
294
- - **Manifest required**: build-time AST scanning creates manifest. Without vitest plugin → "No field metadata"
295
- - **ManifestBuilder fails on scanner errors**: every production manifest path
296
- must abort before adapting partial scan results. A syntax error or unresolved
297
- `@smrt()` config spread cannot be allowed to emit a default-open manifest.
298
- - **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).
299
- - **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).
88
+ The PostgreSQL lane is required for numeric types, UUID casts, conflict targets,
89
+ timestamps, and migrations. Tests generate their manifest before Vitest; restart
90
+ watch mode after adding decorated classes. Documentation-only changes need
91
+ instruction-chain and knowledge freshness checks, not the runtime test suite.
@@ -0,0 +1,92 @@
1
+ # Decorators, build integration, and knowledge
2
+
3
+ Key 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).
4
+
5
+ Registration sets `SMRT_TABLE_NAME` static property (survives minification).
6
+
7
+ ## @field() UI hints (#2046)
8
+
9
+ `@field({ ui: { basic, group, order, locked } })` — a static, presentation-only
10
+ seed for the field-policy rail (epic #2045). Carried in the manifest under the
11
+ field's `_meta.ui` (never a top-level `FieldDefinition` key), readable at
12
+ runtime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with
13
+ `description` into generated web-collection definitions and browser MCP tool
14
+ schemas. No schema/persistence/security effect — `sensitive`/`readPermission`
15
+ stay the security rail, and `sensitive`/`transient` fields never emit to the
16
+ client at all.
17
+
18
+ ## Lightweight discovery
19
+
20
+ `src/knowledge-discovery.ts`, exported through `smrt-core/knowledge`, enumerates
21
+ installed scope directories and reads canonical AGENTS/legacy CLAUDE docs without
22
+ loading package code, artifacts, or scanning objects. CLI snapshots and MCP share
23
+ these primitives. Callers retain selection policy: snapshots resolve links and
24
+ fall back to `packages/*` only when no installed SMRT package loads; MCP preserves
25
+ node_modules paths, deduplicates realpaths, excludes authored workspace links,
26
+ and then enriches the selected packages. Workspace-root/glob discovery remains
27
+ owned by each consumer.
28
+
29
+ ## Domain Knowledge Artifacts
30
+
31
+ `smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:
32
+
33
+ - local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`
34
+ - package build: `dist/manifest.json` and `dist/smrt-knowledge.json`
35
+
36
+ Keep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic
37
+ agent contract for downstream review and architecture tools.
38
+
39
+ The schema-version-1 object projection is additive and high-signal: it retains
40
+ normalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,
41
+ method signatures, and field defaults/constraints/readonly/transient flags.
42
+ Sensitive fields are removed before both `fields` and `relationships` are
43
+ derived, including legacy flags stored under `_meta`; matching field and
44
+ snake-case column names are also removed from projected conflict columns, and a
45
+ sensitive custom tenant field is omitted while retaining scope and mode.
46
+ Generated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;
47
+ the optional marker keeps schema version 1 additive while letting readers
48
+ identify older artifacts that require raw-manifest corroboration.
49
+
50
+ Config precedence for knowledge is defaults → top-level `knowledge` in
51
+ `smrt.config.ts` → `packages[packageName].knowledge` → plugin option →
52
+ object-level `@smrt({ knowledge })`.
53
+
54
+ Object-level `knowledge: false` excludes an object from authored context only;
55
+ it must not change runtime manifest registration. Use
56
+ `knowledge: { tags, summary, risks }` for review-sensitive domain objects.
57
+
58
+ HTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is
59
+ true, generated SvelteKit routes must stay GET-only and guarded by dev mode or
60
+ admin auth.
61
+
62
+
63
+ ## Vite Plugin
64
+
65
+ ```typescript
66
+ // vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)
67
+ export default defineConfig({
68
+ oxc: {
69
+ decorator: {
70
+ legacy: true,
71
+ emitDecoratorMetadata: true,
72
+ },
73
+ },
74
+ });
75
+ ```
76
+
77
+ Under Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`
78
+ recipe (or tsconfig `experimentalDecorators` reached through SvelteKit's
79
+ `extends "./.svelte-kit/tsconfig.json"` chain), so that recipe throws
80
+ `SyntaxError: Invalid or unexpected token` on the first SSR request. Configure
81
+ decorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need
82
+ the legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,
83
+ emitDecoratorMetadata: true`.
84
+
85
+ For independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept
86
+ the same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The
87
+ schema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the
88
+ merged project/dependency manifest, portable source paths, and source-file
89
+ digests; each plugin selects its own view. Reuse mode fails closed on
90
+ byte/provenance/path/content drift, skips scans and manifest writes, and still
91
+ generates routes, types, registration, and virtual modules. Omit it for normal
92
+ local development and watch mode.
package/agents/memory.md CHANGED
@@ -14,3 +14,7 @@ by `@smrt({ embeddings })`, with native pgvector/HNSW or an in-memory fallback.
14
14
  Results hydrate through `list({ 'id in': … })`, so normal tenant isolation still
15
15
  applies. Keep injected search behind the `SmrtCollection.semanticSearch`
16
16
  boundary.
17
+
18
+ `LearningMemory.capture()` reinforces successes and decays failures while
19
+ updating outcome counters. Its tenant-isolated `recall()` applies confidence,
20
+ expiry, time-decay, and hierarchical-scope filters and refreshes `last_used_at`.
@@ -0,0 +1,83 @@
1
+ # Object and collection runtime
2
+
3
+ `constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`
4
+
5
+ - `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided
6
+ - `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)
7
+ - Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws
8
+ `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or
9
+ delete to an earlier snapshot. Remote guarded deletes bind the same predicate
10
+ into the final `DELETE`; embedded adapters compare inside the shared write queue
11
+ before cascading. That queue serializes same-process saves, deletes, and full
12
+ `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this
13
+ public CAS ordering contract. PostgreSQL predicate:
14
+ [revision-guard.md](revision-guard.md).
15
+ - Native DuckDB UUID columns are hydrated as canonical strings before model
16
+ initialization, natural-key lookup, and embedded revision claims. Exact
17
+ natural-key probes retain the interceptor-authorized filter when
18
+ canonicalizing a wrapped identity. Custom embedded-CAS paths that consume
19
+ persisted rows must use `getCanonicalPersistedRow()` so UUID identities are
20
+ cast in the same coherent read before reuse.
21
+ - `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)
22
+ - `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`
23
+ - `getSlug()`: auto-generates from name → title → label → id
24
+ - `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)
25
+
26
+
27
+ ## SmrtCollection Query
28
+
29
+ Projection, latest-related, facets, counts, and bounded read plans are
30
+ documented in [collection-reads.md](collection-reads.md).
31
+
32
+ `list()` and `query()` hydrate model instances serially in result order because
33
+ an `initialize()` hook may query through the same transaction-bound PostgreSQL
34
+ client. Keep this serialization invariant; use `select` when callers need plain
35
+ rows without model hydration.
36
+
37
+ Native DuckDB model hydration casts declared UUID columns to `VARCHAR` in the
38
+ read query because its JavaScript binding otherwise returns lossy HUGEINT
39
+ wrapper objects. Explicit projections apply the same cast for selected UUID
40
+ fields so bounded query envelopes preserve canonical row and relationship ids.
41
+ For STI child columns, raw `query()` SELECTs, and latest-related projections,
42
+ the read path describes the output types without evaluating the query, then
43
+ performs one data-bearing SELECT with UUID result columns cast to `VARCHAR`;
44
+ mutation statements are never reinterpreted or replayed.
45
+
46
+ **WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.
47
+ Arrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`
48
+ renders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.
49
+
50
+ `convertWhereKeys` must accept only operators executable by the SQL builder.
51
+ `contains` and dot-notation JSON paths reject at the boundary; use `like` with
52
+ explicit wildcards. Adding operators requires SQL support first.
53
+ `src/__tests__/issue-2276-where-contract.test.ts` executes the accepted set.
54
+
55
+ STI 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).
56
+
57
+
58
+ ## DispatchBus
59
+
60
+ - `emit(signalType, payload, metadata)` → creates persistent Dispatch record
61
+ - `on(pattern, handler)` → in-memory handler (immediate)
62
+ - `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)
63
+ - `process(subscriberName, handler)` → process pending dispatches
64
+ - Wildcards: `campaign.*` matches `campaign.completed` (single segment only)
65
+ - Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`
66
+ - Status: `pending → processing → completed` (or `failed`)
67
+
68
+ ## Single Table Inheritance (STI)
69
+
70
+ - Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table
71
+ - Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)
72
+ - Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)
73
+ - Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically
74
+ - Validation: fail-fast on save if `_meta_type` missing or mismatched
75
+
76
+ ## Child Accessors (R10)
77
+
78
+ `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:
79
+
80
+ - **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.
81
+ - **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.
82
+
83
+ When 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).