@happyvertical/smrt-core 0.55.12 → 0.55.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/agents/cookbook.md +12 -3
  2. package/agents/object-runtime.md +11 -0
  3. package/dist/collection.d.ts.map +1 -1
  4. package/dist/collection.js +4 -1
  5. package/dist/collection.js.map +1 -1
  6. package/dist/cookbook/engine/document.d.ts.map +1 -1
  7. package/dist/cookbook/engine/document.js +16 -18
  8. package/dist/cookbook/engine/document.js.map +1 -1
  9. package/dist/cookbook/engine/types.d.ts +2 -0
  10. package/dist/cookbook/engine/types.d.ts.map +1 -1
  11. package/dist/cookbook/engine/types.js.map +1 -1
  12. package/dist/cookbook/v1.schema.json +60 -3
  13. package/dist/cookbook/validate.d.ts +40 -4
  14. package/dist/cookbook/validate.d.ts.map +1 -1
  15. package/dist/cookbook/validate.js +228 -58
  16. package/dist/cookbook/validate.js.map +1 -1
  17. package/dist/dispatch/bus.d.ts +9 -4
  18. package/dist/dispatch/bus.d.ts.map +1 -1
  19. package/dist/dispatch/bus.js +11 -6
  20. package/dist/dispatch/bus.js.map +1 -1
  21. package/dist/dispatch/collections/Dispatches.d.ts +15 -5
  22. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  23. package/dist/dispatch/collections/Dispatches.js +29 -9
  24. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  25. package/dist/dispatch/types.d.ts +5 -1
  26. package/dist/dispatch/types.d.ts.map +1 -1
  27. package/dist/manifest/static-manifest.js +1 -1
  28. package/dist/manifest/static-manifest.js.map +1 -1
  29. package/dist/manifest/store.js +1 -1
  30. package/dist/manifest.json +1 -1
  31. package/dist/object.d.ts.map +1 -1
  32. package/dist/object.js +5 -1
  33. package/dist/object.js.map +1 -1
  34. package/dist/smrt-knowledge.json +7 -7
  35. package/dist/utils.d.ts +21 -0
  36. package/dist/utils.d.ts.map +1 -1
  37. package/dist/utils.js +52 -2
  38. package/dist/utils.js.map +1 -1
  39. package/package.json +10 -10
@@ -3,14 +3,14 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.55.12",
6
+ "packageVersion": "0.55.13",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "30a1689c2b52ae2d14feacf684e0c1be04027831d7a2872508ba28e06c01dbb2",
11
- "packageJson": "c83c772875313ce3d38ee5fd75aafef9cb704c9df52309d89c2786340386869b",
10
+ "manifest": "21afa705da0f81cc7ecb2396a3bcaa6ed3aa6651393542877eeb6daff685041b",
11
+ "packageJson": "460be0f65fac5e2b72d25ab9e5a53f73d4c3696dc0e26d99fbb4007bee34ea87",
12
12
  "agents": "a55cc2d7dccf8f5dc9fd515361fddf719466159ee11e265285cf14b3fcdd96fa",
13
- "moduleDoc:agents/object-runtime.md": "652352cdf1eddd0dcc91f6589b8241d9d99581f54bcc2318520a3ff97cd22cf5",
13
+ "moduleDoc:agents/object-runtime.md": "93ba429467c5f3c886613fe5748e14844ceb8d2bbdebd47214a013a8e60b2a06",
14
14
  "moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
15
15
  "moduleDoc:agents/collection-reads.md": "e5bb79b59c17e79a85a71580a019e6b0b641e498ac4d850073fd5ad634b57cb7",
16
16
  "moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5",
@@ -26,7 +26,7 @@
26
26
  "moduleDoc:agents/model-moves.md": "c64147fee1b7ec242fd1c178e7aea7cc90cafc704dee2738141c7a6d61403cd8",
27
27
  "moduleDoc:agents/run-once.md": "0b858c18c78eca5050257a2af06f16de99327e05e0bf9fee9e6277242e7707ef",
28
28
  "moduleDoc:agents/recipes.md": "50ddedb14583e073c4af9c15142f4838c4326c1955931f743148e41e15dbdf38",
29
- "moduleDoc:agents/cookbook.md": "61e1dc00085764a6cbd473f64eced1a53745cab0edd5b7770f2cc7e69fd4e3b2",
29
+ "moduleDoc:agents/cookbook.md": "810faf651b667816ad075a46149c4d0b532275649bd091d39736ebb968857af3",
30
30
  "moduleDoc:agents/cookbook-engine.md": "d4943b15e7b90a133b28a4c86b498c16eb623cd78b5022585873276cb1a5a5ab",
31
31
  "moduleDoc:agents/browser-entry.md": "04d26eff8a65f02e88b20cbb77b1f94f58e29b62178ac6fbec129ee436a732eb",
32
32
  "moduleDoc:agents/test-types.md": "37c009ae2bd21001d83bba6cfab2047465fe32d5443de12661e24ba7d4e2d613"
@@ -1141,7 +1141,7 @@
1141
1141
  {
1142
1142
  "path": "agents/object-runtime.md",
1143
1143
  "module": "object-runtime",
1144
- "content": "# Object and collection runtime\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\nModels declaring `audit: true` use transactional collection CRUD with trusted\n`SmrtCollectionOptions.auditTrail` identity and writer. `collection.update()` checks\nregistered mutable fields; audited creates refuse natural-key overwrites.\nDirect object writes and custom overrides need explicit service recording.\nCascade reuse of a no-savepoint transaction requires an active framework-owned\ntransaction handle; raw caller transactions retain the ordinary delete limitation.\nSee [audit contract](../../../docs/content/audit-trail.md).\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [revision-guard.md](revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `normalizePersistenceData(data)` is the synchronous final derived-column hook:\n shared save preparation passes read-only snake-case row data after the complete polymorphic\n `toJSON()` / `transformJSON()` chain and UUID coercion, then merges the returned\n columns before every insert/update/upsert branch. Declare permitted snake-case\n columns with `getPersistenceDerivedColumns()`, preserving super declarations.\n Declarations must name registered schema fields; framework identity, tenant,\n revision, STI metadata and natural conflict columns are always rejected.\n Undeclared return keys reject before persistence. The hook receives a frozen\n shallow row copy. Derive only the declared columns; do not perform I/O.\n Preserve `super` results. It does not alter plain/public serialization;\n use `transformJSON()` for that existing contract. Ordinary saves and eligible\n junction batches share this preparation. A custom normalization override makes\n a junction ineligible for batching, preserving its virtual per-row save path.\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\nRelationship I/O resolves canonical source and target identities, not the public\ndisplay names. `relationship-loader.ts` owns target manifest hydration and inverse\nselection for lazy, eager, junction and latest-related reads. An unresolved exact\nconstructor or ambiguous target fails before querying or caching a foreign peer;\nlegacy external string targets retain manifest discovery. Junction column naming\nconventions still use display names. Cached reads retain their tenant rechecks.\n\n\n## Plain-object serialization\n\n`toPlainObject()` materializes the `toJSON()` / `transformJSON()` result directly,\nwithout an intermediate JSON string for ordinary payloads. Native `JSON.rawJSON()`\nliterals are decoded once with `JSON.parse()`. Nested hooks and JSON omission/coercion\nrules still apply. The active ancestor stack rejects cycles while allowing\nshared siblings, which become independent plain copies. Arrays skip boxed-value\nbrand checks; other objects use intrinsic brand checks without reading user\n`Symbol.toStringTag` getters. Customize the payload through `transformJSON()`.\nThe focused `to-plain-object.test.ts` suite compares legacy output and reports\nwarmed, interleaved per-row timings without a flaky timing assertion.\n\n`pnpm --filter @happyvertical/smrt-core test:browser` bundles the production\n`src/plain-json.ts` helper and executes it in Chromium with `process` and\n`JSON.isRawJSON` unavailable. It is an opt-in local/browser release gate rather\nthan part of the default core suite: it requires the repository's\n`playwright-core` dependency and an installed Chromium binary. Set\n`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chromium` to use an installed\nexecutable; otherwise Playwright's managed Chromium is used. The normal test\nsuite covers the same helper through `toPlainObject()` on Node.\n\n## SmrtCollection Query\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [collection-reads.md](collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\n`convertWhereKeys` must accept only operators executable by the SQL builder.\n`contains` and dot-notation JSON paths reject at the boundary; use `like` with\nexplicit wildcards. Adding operators requires SQL support first.\n`src/__tests__/issue-2276-where-contract.test.ts` executes the accepted set.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [query-bounds.md](query-bounds.md).\n\n\n## Junction reads and compatible writes\n\n`byLeft(id, { relationship: 'attachment', limit: 20, offset: 40 })` and\n`byRight()` forward `limit`/`offset` to the collection's existing bounds parser;\nother options remain field filters. Pagination is read-only: `detach()` still\nrequires field filters and never deletes a page. Cursor pagination is not part\nof this API; callers can use deterministic limit/offset pages.\n\n`setLinks()` preserves replacement semantics: even retained right IDs get new\njunction IDs/timestamps and delete/create change entries. `detach()` and\n`setLinks()` batch compatible models automatically. Within 100 removed + added\nrows and 900 inserted bind values, a warm SQLite/DuckDB/PostgreSQL operation uses\nconstant framework SQL: one snapshot, grouped owned-memory cleanup and delete,\none multi-row natural-key upsert, and one feed append per nonempty mutation\nphase. Bootstrap, retries, and caller-defined work are excluded. The read\nsnapshot and in-memory work remain proportional to link count.\n\nEligibility is deliberately conservative and rechecked each call: base runtime\nmethods/accessors and collection create/attach behavior, one non-STI table,\ncompiler-owned declarative validators, no validated cross-package references,\nno embedding generation, no incoming typed references, non-NULL unique\nconflict values with no duplicate input conflict keys, and explicit consent\nfrom every mutation interceptor. Otherwise the original virtual per-row path\nruns. Larger sets retain that fallback; there is no truncated replacement.\nPolymorphic association cleanup remains grouped through the owning cascade API\nand invalidates every affected table. JSON export adapters retain their ordinary\nlifecycle; unrecognized transaction-only DuckDB handles also fall back.\nThe tenancy interceptor consents only without directory dispatch/custom error\ncallbacks. Tenant checks and auto-population still run per row. The change-feed\ninterceptor appends every row and preserves tenant IDs and tombstones.\n\nBulk lifecycle preparation and completion are owned by `SmrtObject`; collection\ninitialization shares `createUnsaved()` with `create()`. Additional interceptors\nmay supply `bulkMutation.compatible()` only if grouped before/persist/after\nphases preserve their behavior and before hooks have no side effects beyond\ninstance/context mutation. Preparation can discover an unsupported shape and\nfall back; before hooks must tolerate that rehearsal. Unknown interceptors never\nopt in implicitly.\nThe compiler marks its own callback-free validators by function identity.\n\nThe whole replacement remains nontransactional unless the caller supplies a\ntransaction. A delete group is atomic with its owned-memory cleanup and each\nmulti-row insert is atomic, so database failure can leave the deletion phase\ncommitted without new links. No per-row prefix is promised for a failed batch.\nThe batch delete reuses an existing DuckDB transaction when the SDK explicitly\nrefuses savepoint nesting before running any work; it never replays a callback.\nThe legacy fallback retains its existing detectable nested-transaction refusal\nfor ordinary deletes inside caller-owned DuckDB transactions\n([#2824](https://github.com/happyvertical/smrt/issues/2824)).\nPublic ordinary `save()` still owns revision CAS; batches only create new\ninstances and never use this path to overwrite a loaded revision.\n\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\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"
1144
+ "content": "# Object and collection runtime\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\nModels declaring `audit: true` use transactional collection CRUD with trusted\n`SmrtCollectionOptions.auditTrail` identity and writer. `collection.update()` checks\nregistered mutable fields; audited creates refuse natural-key overwrites.\nDirect object writes and custom overrides need explicit service recording.\nCascade reuse of a no-savepoint transaction requires an active framework-owned\ntransaction handle; raw caller transactions retain the ordinary delete limitation.\nSee [audit contract](../../../docs/content/audit-trail.md).\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [revision-guard.md](revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `normalizePersistenceData(data)` is the synchronous final derived-column hook:\n shared save preparation passes read-only snake-case row data after the complete polymorphic\n `toJSON()` / `transformJSON()` chain and UUID coercion, then merges the returned\n columns before every insert/update/upsert branch. Declare permitted snake-case\n columns with `getPersistenceDerivedColumns()`, preserving super declarations.\n Declarations must name registered schema fields; framework identity, tenant,\n revision, STI metadata and natural conflict columns are always rejected.\n Undeclared return keys reject before persistence. The hook receives a frozen\n shallow row copy. Derive only the declared columns; do not perform I/O.\n Preserve `super` results. It does not alter plain/public serialization;\n use `transformJSON()` for that existing contract. Ordinary saves and eligible\n junction batches share this preparation. A custom normalization override makes\n a junction ineligible for batching, preserving its virtual per-row save path.\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\nRelationship I/O resolves canonical source and target identities, not the public\ndisplay names. `relationship-loader.ts` owns target manifest hydration and inverse\nselection for lazy, eager, junction and latest-related reads. An unresolved exact\nconstructor or ambiguous target fails before querying or caching a foreign peer;\nlegacy external string targets retain manifest discovery. Junction column naming\nconventions still use display names. Cached reads retain their tenant rechecks.\n\n\n## Plain-object serialization\n\n`toPlainObject()` materializes the `toJSON()` / `transformJSON()` result directly,\nwithout an intermediate JSON string for ordinary payloads. Native `JSON.rawJSON()`\nliterals are decoded once with `JSON.parse()`. Nested hooks and JSON omission/coercion\nrules still apply. The active ancestor stack rejects cycles while allowing\nshared siblings, which become independent plain copies. Arrays skip boxed-value\nbrand checks; other objects use intrinsic brand checks without reading user\n`Symbol.toStringTag` getters. Customize the payload through `transformJSON()`.\nThe focused `to-plain-object.test.ts` suite compares legacy output and reports\nwarmed, interleaved per-row timings without a flaky timing assertion.\n\n`pnpm --filter @happyvertical/smrt-core test:browser` bundles the production\n`src/plain-json.ts` helper and executes it in Chromium with `process` and\n`JSON.isRawJSON` unavailable. It is an opt-in local/browser release gate rather\nthan part of the default core suite: it requires the repository's\n`playwright-core` dependency and an installed Chromium binary. Set\n`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chromium` to use an installed\nexecutable; otherwise Playwright's managed Chromium is used. The normal test\nsuite covers the same helper through `toPlainObject()` on Node.\n\n## SmrtCollection Query\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [collection-reads.md](collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\n`convertWhereKeys` must accept only operators executable by the SQL builder.\n`contains` and dot-notation JSON paths reject at the boundary; use `like` with\nexplicit wildcards. Adding operators requires SQL support first.\n`src/__tests__/issue-2276-where-contract.test.ts` executes the accepted set.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [query-bounds.md](query-bounds.md).\n\n\n## Junction reads and compatible writes\n\n`byLeft(id, { relationship: 'attachment', limit: 20, offset: 40 })` and\n`byRight()` forward `limit`/`offset` to the collection's existing bounds parser;\nother options remain field filters. Pagination is read-only: `detach()` still\nrequires field filters and never deletes a page. Cursor pagination is not part\nof this API; callers can use deterministic limit/offset pages.\n\n`setLinks()` preserves replacement semantics: even retained right IDs get new\njunction IDs/timestamps and delete/create change entries. `detach()` and\n`setLinks()` batch compatible models automatically. Within 100 removed + added\nrows and 900 inserted bind values, a warm SQLite/DuckDB/PostgreSQL operation uses\nconstant framework SQL: one snapshot, grouped owned-memory cleanup and delete,\none multi-row natural-key upsert, and one feed append per nonempty mutation\nphase. Bootstrap, retries, and caller-defined work are excluded. The read\nsnapshot and in-memory work remain proportional to link count.\n\nEligibility is deliberately conservative and rechecked each call: base runtime\nmethods/accessors and collection create/attach behavior, one non-STI table,\ncompiler-owned declarative validators, no validated cross-package references,\nno embedding generation, no incoming typed references, non-NULL unique\nconflict values with no duplicate input conflict keys, and explicit consent\nfrom every mutation interceptor. Otherwise the original virtual per-row path\nruns. Larger sets retain that fallback; there is no truncated replacement.\nPolymorphic association cleanup remains grouped through the owning cascade API\nand invalidates every affected table. JSON export adapters retain their ordinary\nlifecycle; unrecognized transaction-only DuckDB handles also fall back.\nThe tenancy interceptor consents only without directory dispatch/custom error\ncallbacks. Tenant checks and auto-population still run per row. The change-feed\ninterceptor appends every row and preserves tenant IDs and tombstones.\n\nBulk lifecycle preparation and completion are owned by `SmrtObject`; collection\ninitialization shares `createUnsaved()` with `create()`. Additional interceptors\nmay supply `bulkMutation.compatible()` only if grouped before/persist/after\nphases preserve their behavior and before hooks have no side effects beyond\ninstance/context mutation. Preparation can discover an unsupported shape and\nfall back; before hooks must tolerate that rehearsal. Unknown interceptors never\nopt in implicitly.\nThe compiler marks its own callback-free validators by function identity.\n\nThe whole replacement remains nontransactional unless the caller supplies a\ntransaction. A delete group is atomic with its owned-memory cleanup and each\nmulti-row insert is atomic, so database failure can leave the deletion phase\ncommitted without new links. No per-row prefix is promised for a failed batch.\nThe batch delete reuses an existing DuckDB transaction when the SDK explicitly\nrefuses savepoint nesting before running any work; it never replays a callback.\nThe legacy fallback retains its existing detectable nested-transaction refusal\nfor ordinary deletes inside caller-owned DuckDB transactions\n([#2824](https://github.com/happyvertical/smrt/issues/2824)).\nPublic ordinary `save()` still owns revision CAS; batches only create new\ninstances and never use this path to overwrite a loaded revision.\n\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n- Tenancy (`dispatch/tenant-resolver.ts`): `emit` stamps the server-derived\n tenant; no context stores `tenant_id` NULL (global). Under an active tenant,\n processing (`process`/`retry`/`cleanup`) matches that tenant plus global rows,\n but inspection reads (`list`/`get`) match that tenant only — global rows are\n system-only because their payload may belong to another tenant (#3301). Emit\n tenant-owned deliveries inside the tenant context so the owner can see them.\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Columns win over `_meta_data` (#3238): `formatDataJs` never lets a meta key\n shadow a column the row carries, except a field the class declares\n `@meta()` (a same-named column then belongs to an STI sibling). `id`, tenant-ownership and `_`-prefixed\n keys (`isReservedMetaDataKey`, inherited tenant fields included) are dropped\n from the blob on hydration and save, object or JSON text (`sanitizeMetaData`).\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n"
1145
1145
  },
1146
1146
  {
1147
1147
  "path": "agents/revision-guard.md",
@@ -1221,7 +1221,7 @@
1221
1221
  {
1222
1222
  "path": "agents/cookbook.md",
1223
1223
  "module": "cookbook",
1224
- "content": "# Cookbook v1 contract (#3747, #3748)\n\nA cookbook is the portable JSON document that says what an app is made of.\nDefined once here so the CLI (`smrt cookbook validate|apply`) and the cookbook\napp shell read the same thing.\n\n| Piece | Where |\n|---|---|\n| Types (type-only) | `@happyvertical/smrt-types`: `Cookbook`, `CookbookPolicyRow`, `CookbookLayout`, `CookbookTheme`, `CookbookOverviewOverride`, `CookbookExposureSurface` |\n| Parse / validate / migrate (browser-safe) | `@happyvertical/smrt-core/cookbook`: `validateCookbook(doc, { recipes? })`, `parseCookbookText`, `COOKBOOK_SCHEMA_URL`, `COOKBOOK_LEGACY_SCHEMA_URL`, `COOKBOOK_VERSION` |\n| JSON Schema | `@happyvertical/smrt-core/cookbook/v1.schema.json` (`$id` is the `$schema` URL; s-m-r-t.dev hosts it later) |\n| App shell | `@happyvertical/smrt-svelte/cookbook` renders a cookbook; `loadCookbookApp` in `/cookbook/server` loads and validates it ([cookbook-shell.md](../../smrt-svelte/agents/cookbook-shell.md)) |\n| Fixtures | `src/cookbook/__fixtures__/` (the planner's bakery, mechanic, welder, yoga-studio documents) |\n\nFields: `$schema`, `version: 1`, optional `name`/`description`, `recipes`,\n`features` (absent reads as `[]`), `policies` (app-scope smrt-fields rows),\n`exposure`, `layout` (smrt-svelte `ShellLayout`), `theme`, `overviews`\n(smrt-svelte `OverviewOverride` per overview id). Sample data is not part of\nthe document; the planner's library wrapper (id, icon, keywords, settings) is\nplanner-side.\n\n## Rules\n\n- Versioning: add optional fields within `version: 1`; readers keep and ignore\n unknown keys (`additionalProperties: true`). A breaking change is `version: 2`.\n- Layout and overview values are typed structurally so smrt-types and core never\n depend on smrt-svelte; smrt-svelte owns deep validation (widgets against page\n definitions, nav ids). `CookbookLayout`/`CookbookOverviewOverride` are\n identical to the smrt-svelte types; a typechecked assertion in smrt-svelte\n (`cookbook/__tests__/contract-types.ts`) fails when either side adds a field.\n- Legacy `.../blueprint/v1.json` is accepted on read and rewritten; never written.\n- Recipe existence, `requires`/`requiresAny` and setting targets need manifests:\n `validateCookbook(doc, { recipes })` checks ids when a catalog is passed;\n the rest is the CLI's job.\n- Keep the JSON Schema and `validate.ts` in step; the test validates the same\n fixtures against both.\n\n## Applying a cookbook\n\n`smrt cookbook validate <file|url>` and `smrt cookbook apply <file|url> [dir]`\n(`packages/cli/agents/cookbook-apply.md`) are the consumers. Apply writes\n`smrt.cookbook.json` at the project root, plus the packages the recipes,\nfeatures and policies need, into `package.json`. The file is the cookbook\nverbatim (legacy `$schema` rewritten); apps read it, apply does not interpret it.\n`policies` is required by the validator (use `[]`). The cookbook carries no package\nversions, so apply uses the project's smrt line.\n\n## Hosting package models\n\nAn app that applies a cookbook lists the recipes' packages in `consumer.packages`\nand nothing else: the `smrt()` Vite preset derives the REST allowlist from the\ncookbook, so the Orders of a `commerce.sales` recipe get `/api/orders` routes and\nan `@smrt/web` definition (see [build-knowledge.md](build-knowledge.md), \"Package\nmodels over REST\"). `exposure` listing `api` for a model withdraws its route. Models that share an\nSTI table (the bakery's `Order`, `WholesaleOrder`, `PurchaseOrder` and\n`ProductionOrder`; `Product` and `Material`) each get their own route and\ndefinition, at `/<class collection>` (`/orders`, `/productionorders`), listing\nonly their own rows; the build-knowledge section explains why.\n\nPermissions do not follow the route. The permission catalog grants one namespace\nper STI table, the base's (`contracts.create`; there is no `orders.create`), so a\nhosted subclass moved to its own route collection keeps authorizing against the\nbase's slugs: the hosting rewrite records the original collection as\n`permissionCollection`, which the generated route guard and `sync/apply` read\nbefore the (rewritten) route collection. It matches the fail-closed `api`\ninheritance and the CLI/MCP operation guards, which already derive the base's\nslug. No role is widened: a caller needs exactly the grants the base requires.\nReads need no operation slug. A subclass that declares its own `collection` has\nits own namespace and is untouched.\n\n## Editing a cookbook\n\nThe command engine (`@happyvertical/smrt-core/cookbook/engine`) is the one place\nthat changes a cookbook: see [cookbook-engine.md](cookbook-engine.md).\n"
1224
+ "content": "# Cookbook v1 contract (#3747, #3748)\n\nA cookbook is the portable JSON document that says what an app is made of.\nDefined once here so the CLI (`smrt cookbook validate|apply`) and the cookbook\napp shell read the same thing.\n\n| Piece | Where |\n|---|---|\n| Types (type-only) | `@happyvertical/smrt-types`: `Cookbook`, `CookbookPolicyRow`, `CookbookLayout`, `CookbookTheme`, `CookbookOverviewOverride`, `CookbookExposureSurface` |\n| Parse / validate / migrate (browser-safe) | `@happyvertical/smrt-core/cookbook`: `validateCookbook(doc, options)`, `parseCookbookText`, `CookbookDiagnostic`, `COOKBOOK_SCHEMA_URL`, `COOKBOOK_LEGACY_SCHEMA_URL`, `COOKBOOK_VERSION` |\n| JSON Schema | `@happyvertical/smrt-core/cookbook/v1.schema.json` (`$id` is the `$schema` URL; s-m-r-t.dev hosts it later) |\n| App shell | `@happyvertical/smrt-svelte/cookbook` renders a cookbook; `loadCookbookApp` in `/cookbook/server` loads and validates it ([cookbook-shell.md](../../smrt-svelte/agents/cookbook-shell.md)) |\n| Fixtures | `src/cookbook/__fixtures__/` (the planner's bakery, mechanic, welder, yoga-studio documents) |\n\nFields: `$schema`, `version: 1`, optional `name`/`description`, `recipes`,\n`features` (absent reads as `[]`), `policies` (app-scope smrt-fields rows),\n`exposure`, `layout` (smrt-svelte `ShellLayout`), `theme`, `overviews`\n(smrt-svelte `OverviewOverride` per overview id). Sample data is not part of\nthe document; the planner's library wrapper (id, icon, keywords, settings) is\nplanner-side.\n\n## Rules\n\n- Versioning: add optional fields within `version: 1`; readers keep and ignore\n unknown keys (`additionalProperties: true`). A breaking change is `version: 2`.\n- Layout and overview values are typed structurally so smrt-types and core never\n depend on smrt-svelte. Hosts pass manifest-derived navigation ids and resolved\n shell `placementItems` (dock, slot-content, recipe, and host ids) in `layout`,\n `themePresets`, `overviewIds`, and a `validateOverview` callback to the core\n validator for checks that need resolved pages or a widget registry. The\n callback returns canonical plain data; cookbook values are never executed.\n Malformed or host-rejected overview entries are dropped and reported as\n warning diagnostics, while a malformed top-level `overviews` field rejects\n the document. `CookbookLayout`/`CookbookOverviewOverride` are\n identical to the smrt-svelte types; a typechecked assertion in smrt-svelte\n (`cookbook/__tests__/contract-types.ts`) fails when either side adds a field.\n- Legacy `.../blueprint/v1.json` is accepted on read and rewritten; never written.\n- Recipe existence, `requires`/`requiresAny` and setting targets need manifests:\n `validateCookbook(doc, { recipes })` checks ids when a catalog is passed;\n the rest is the CLI's job.\n- Every result includes structured `diagnostics` (`severity`, stable `code`,\n `path`, `message`). The existing `errors` and `warnings` string arrays remain\n for compatibility and are derived from those diagnostics.\n- Keep the JSON Schema and `validate.ts` in step; the test validates the same\n fixtures against both.\n\n## Applying a cookbook\n\n`smrt cookbook validate <file|url>` and `smrt cookbook apply <file|url> [dir]`\n(`packages/cli/agents/cookbook-apply.md`) are the consumers. Apply writes\n`smrt.cookbook.json` at the project root, plus the packages the recipes,\nfeatures and policies need, into `package.json`. The file is the cookbook\nverbatim (legacy `$schema` rewritten); apps read it, apply does not interpret it.\n`policies` is required by the validator (use `[]`). The cookbook carries no package\nversions, so apply uses the project's smrt line.\n\n## Hosting package models\n\nAn app that applies a cookbook lists the recipes' packages in `consumer.packages`\nand nothing else: the `smrt()` Vite preset derives the REST allowlist from the\ncookbook, so the Orders of a `commerce.sales` recipe get `/api/orders` routes and\nan `@smrt/web` definition (see [build-knowledge.md](build-knowledge.md), \"Package\nmodels over REST\"). `exposure` listing `api` for a model withdraws its route. Models that share an\nSTI table (the bakery's `Order`, `WholesaleOrder`, `PurchaseOrder` and\n`ProductionOrder`; `Product` and `Material`) each get their own route and\ndefinition, at `/<class collection>` (`/orders`, `/productionorders`), listing\nonly their own rows; the build-knowledge section explains why.\n\nPermissions do not follow the route. The permission catalog grants one namespace\nper STI table, the base's (`contracts.create`; there is no `orders.create`), so a\nhosted subclass moved to its own route collection keeps authorizing against the\nbase's slugs: the hosting rewrite records the original collection as\n`permissionCollection`, which the generated route guard and `sync/apply` read\nbefore the (rewritten) route collection. It matches the fail-closed `api`\ninheritance and the CLI/MCP operation guards, which already derive the base's\nslug. No role is widened: a caller needs exactly the grants the base requires.\nReads need no operation slug. A subclass that declares its own `collection` has\nits own namespace and is untouched.\n\n## Editing a cookbook\n\nThe command engine (`@happyvertical/smrt-core/cookbook/engine`) is the one place\nthat changes a cookbook: see [cookbook-engine.md](cookbook-engine.md).\n"
1225
1225
  },
1226
1226
  {
1227
1227
  "path": "agents/cookbook-engine.md",
package/dist/utils.d.ts CHANGED
@@ -149,6 +149,27 @@ export declare function generateTableRenameMigrations(classNames: string[]): str
149
149
  * ```
150
150
  */
151
151
  export declare function tableNameFromClass(ClassType: Function | (new (...args: never[]) => SmrtObject)): string;
152
+ /**
153
+ * Keys that may never be read from (or written to) the STI `_meta_data` blob
154
+ * (#3238): framework `_`-prefixed keys, the primary key, and tenant-ownership
155
+ * fields. These identities live in real columns only; a meta key of the same
156
+ * name is untrusted payload and is dropped rather than allowed to shadow them.
157
+ *
158
+ * @param key - A `_meta_data` key (camelCase or snake_case)
159
+ * @param fields - Optional field definitions; any field carrying a tenancy
160
+ * marker (`__tenancy.isTenantIdField`) is reserved under its own name too
161
+ */
162
+ export declare function isReservedMetaDataKey(key: string, fields?: Record<string, unknown>): boolean;
163
+ /**
164
+ * Returns the STI `_meta_data` value with every reserved key
165
+ * (`isReservedMetaDataKey`) removed, in the representation it arrived in: an
166
+ * object stays an object, a JSON string is re-serialized (#3238). A value with
167
+ * no reserved key, or one that is not a JSON object, is returned unchanged.
168
+ *
169
+ * @param metaData - `_meta_data` as stored (JSON text) or already parsed
170
+ * @param fields - Optional field definitions (see `isReservedMetaDataKey`)
171
+ */
172
+ export declare function sanitizeMetaData(metaData: unknown, fields?: Record<string, unknown>): unknown;
152
173
  /**
153
174
  * Formats data for JavaScript by converting date strings to Date objects
154
175
  * and snake_case column names to camelCase properties
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAU,CAAC;AAG3C,OAAO,EACL,oBAAoB,EACpB,SAAS,EACT,WAAW,EACZ,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAQ9E,OAAO,EACL,oBAAoB,EACpB,SAAS,EACT,oBAAoB,EACpB,aAAa,EACb,WAAW,GACZ,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAMzB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAMzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,WAItC;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,QAK/C;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,UAK/C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,eAAe,CACnC,SAAS,EAAE,KAAK,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,UAAU,EAC/C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,oCA6CjC;AAMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAMpE;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,6BAA6B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAa5E;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,kBAAkB,CAEhC,SAAS,EAAE,QAAQ,GAAG,CAAC,KAAK,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,UAAU,CAAC,UAiB7D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAC1B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,IAAI,EAAE,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,CAAC,CAqIxD;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAE9D;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,2BAc1D"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAU,CAAC;AAG3C,OAAO,EACL,oBAAoB,EACpB,SAAS,EACT,WAAW,EACZ,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAQ9E,OAAO,EACL,oBAAoB,EACpB,SAAS,EACT,oBAAoB,EACpB,aAAa,EACb,WAAW,GACZ,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAMzB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAMzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,WAItC;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,QAK/C;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,UAK/C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,eAAe,CACnC,SAAS,EAAE,KAAK,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,UAAU,EAC/C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,oCA6CjC;AAMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAMpE;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,6BAA6B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAa5E;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,kBAAkB,CAEhC,SAAS,EAAE,QAAQ,GAAG,CAAC,KAAK,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,UAAU,CAAC,UAiB7D;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAcT;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,OAAO,EACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAiBT;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAC1B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,IAAI,EAAE,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,CAAC,CAiKxD;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAE9D;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,2BAc1D"}
package/dist/utils.js CHANGED
@@ -200,6 +200,46 @@ function tableNameFromClass(ClassType) {
200
200
  return pluralize(ClassType.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase());
201
201
  }
202
202
  /**
203
+ * Keys that may never be read from (or written to) the STI `_meta_data` blob
204
+ * (#3238): framework `_`-prefixed keys, the primary key, and tenant-ownership
205
+ * fields. These identities live in real columns only; a meta key of the same
206
+ * name is untrusted payload and is dropped rather than allowed to shadow them.
207
+ *
208
+ * @param key - A `_meta_data` key (camelCase or snake_case)
209
+ * @param fields - Optional field definitions; any field carrying a tenancy
210
+ * marker (`__tenancy.isTenantIdField`) is reserved under its own name too
211
+ */
212
+ function isReservedMetaDataKey(key, fields) {
213
+ if (key.startsWith("_")) return true;
214
+ const camelKey = toCamelCase(key);
215
+ if (camelKey === "id" || camelKey === "tenantId") return true;
216
+ const field = fields?.[camelKey] ?? fields?.[key];
217
+ return Boolean(field?.__tenancy?.isTenantIdField || field?._meta?.__tenancy?.isTenantIdField);
218
+ }
219
+ /**
220
+ * Returns the STI `_meta_data` value with every reserved key
221
+ * (`isReservedMetaDataKey`) removed, in the representation it arrived in: an
222
+ * object stays an object, a JSON string is re-serialized (#3238). A value with
223
+ * no reserved key, or one that is not a JSON object, is returned unchanged.
224
+ *
225
+ * @param metaData - `_meta_data` as stored (JSON text) or already parsed
226
+ * @param fields - Optional field definitions (see `isReservedMetaDataKey`)
227
+ */
228
+ function sanitizeMetaData(metaData, fields) {
229
+ let parsed = metaData;
230
+ if (typeof metaData === "string") try {
231
+ parsed = JSON.parse(metaData);
232
+ } catch {
233
+ return metaData;
234
+ }
235
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return metaData;
236
+ const entries = Object.entries(parsed);
237
+ const kept = entries.filter(([key]) => !isReservedMetaDataKey(key, fields));
238
+ if (kept.length === entries.length) return metaData;
239
+ const sanitized = Object.fromEntries(kept);
240
+ return typeof metaData === "string" ? JSON.stringify(sanitized) : sanitized;
241
+ }
242
+ /**
203
243
  * Formats data for JavaScript by converting date strings to Date objects
204
244
  * and snake_case column names to camelCase properties
205
245
  *
@@ -230,8 +270,18 @@ function formatDataJs(data, fields) {
230
270
  });
231
271
  mergedData = {
232
272
  ...data,
233
- ...metaData
273
+ _meta_data: sanitizeMetaData(data._meta_data, fields)
234
274
  };
275
+ const columnKeys = new Set(Object.keys(data).map((key) => key.startsWith("_") ? key : toCamelCase(key)));
276
+ for (const [key, value] of Object.entries(metaData)) {
277
+ if (isReservedMetaDataKey(key, fields)) continue;
278
+ const camelKey = toCamelCase(key);
279
+ if (columnKeys.has(camelKey)) {
280
+ if ((fields?.[camelKey] ?? fields?.[key])?.type !== "meta") continue;
281
+ for (const column of Object.keys(data)) if (toCamelCase(column) === camelKey) delete mergedData[column];
282
+ }
283
+ mergedData[key] = value;
284
+ }
235
285
  if (process.env.DEBUG_STI) logger.debug("[formatDataJs] After merge, data keys", { keys: Object.keys(mergedData) });
236
286
  }
237
287
  for (const [key, value] of Object.entries(mergedData)) {
@@ -294,6 +344,6 @@ function formatDataSql(data) {
294
344
  return normalizedData;
295
345
  }
296
346
  //#endregion
297
- export { classnameToTablename, dateAsObject, dateAsString, fieldsFromClass, formatDataJs, formatDataSql, generateTableRenameMigrations, isDateField, isFieldInstance, keysToCamelCase, keysToSnakeCase, legacyClassnameToTablename, pluralize, tableNameFromClass, toCamelCase, toSafeBooleanInteger, toSafeInteger, toSnakeCase };
347
+ export { classnameToTablename, dateAsObject, dateAsString, fieldsFromClass, formatDataJs, formatDataSql, generateTableRenameMigrations, isDateField, isFieldInstance, isReservedMetaDataKey, keysToCamelCase, keysToSnakeCase, legacyClassnameToTablename, pluralize, sanitizeMetaData, tableNameFromClass, toCamelCase, toSafeBooleanInteger, toSafeInteger, toSnakeCase };
298
348
 
299
349
  //# sourceMappingURL=utils.js.map
package/dist/utils.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import { createLogger } from '@happyvertical/logger';\nimport type { SmrtObject } from './object';\nimport { ObjectRegistry } from './registry';\nimport type { SmrtObjectConstructor } from './registry/types';\nimport {\n classnameToTablename,\n pluralize,\n toSnakeCase,\n} from './utils/naming.js';\nimport { toSafeBooleanInteger, toSafeInteger } from './utils/safe-integer.js';\n\n// formatDataJs' debug traces are gated by DEBUG_STI, so the level must allow\n// debug when it's set (a fixed 'info' would filter them out and break the flag).\nconst logger = createLogger({\n level: process.env.DEBUG_STI ? 'debug' : 'info',\n});\n\nexport {\n classnameToTablename,\n pluralize,\n toSafeBooleanInteger,\n toSafeInteger,\n toSnakeCase,\n};\n\n/**\n * Converts a snake_case string to camelCase\n *\n * @param str - String in snake_case format\n * @returns String in camelCase format\n * @example\n * ```typescript\n * toCamelCase('meetings_url'); // 'meetingsUrl'\n * toCamelCase('created_at'); // 'createdAt'\n * toCamelCase('id'); // 'id'\n * ```\n */\nexport function toCamelCase(str: string): string {\n return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());\n}\n\n/**\n * Converts all keys in an object from camelCase to snake_case\n *\n * @param obj - Object with camelCase keys\n * @returns Object with snake_case keys\n */\nexport function keysToSnakeCase(\n obj: Record<string, unknown>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n result[toSnakeCase(key)] = value;\n }\n return result;\n}\n\n/**\n * Converts all keys in an object from snake_case to camelCase\n *\n * @param obj - Object with snake_case keys\n * @returns Object with camelCase keys\n */\nexport function keysToCamelCase(\n obj: Record<string, unknown>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n result[toCamelCase(key)] = value;\n }\n return result;\n}\n\n/**\n * Checks if a field name indicates a date field based on naming conventions\n *\n * Recognizes common date field patterns like '_at', '_date', and 'date'.\n * Used for automatic type inference during schema generation.\n *\n * @param key - Field name to check\n * @returns Boolean indicating if the field is likely a date field\n * @example\n * ```typescript\n * isDateField('created_at'); // true\n * isDateField('updated_date'); // true\n * isDateField('name'); // false\n * ```\n */\nexport function isDateField(key: string) {\n // Fallback pattern matching when schema is not available\n // Primary date detection should use schema field types\n return key.endsWith('_date') || key.endsWith('_at') || key === 'date';\n}\n\n/**\n * Converts a date string to a Date object\n *\n * @param date - Date as string or Date object\n * @returns Date object\n */\nexport function dateAsString(date: Date | string) {\n if (typeof date === 'string') {\n return new Date(date);\n }\n return date;\n}\n\n/**\n * Converts a Date object to an ISO string\n *\n * @param date - Date as Date object or string\n * @returns ISO date string or the original string\n */\nexport function dateAsObject(date: Date | string) {\n if (date instanceof Date) {\n return date.toISOString();\n }\n return date;\n}\n\n/**\n * Extracts field definitions from a class constructor\n *\n * Uses ObjectRegistry cached fields from AST manifest exclusively.\n * No runtime introspection fallback - classes must be decorated with @smrt()\n * for schema generation to work.\n *\n * @param ClassType - Class constructor to extract fields from\n * @param values - Optional values to set for the fields\n * @returns Object containing field definitions with names, types, and values\n * @throws {Error} If the class is not registered in ObjectRegistry\n * @example\n * ```typescript\n * @smrt()\n * class Product extends SmrtObject {\n * name: string = '';\n * price: number = 0.0;\n * }\n *\n * const fields = await fieldsFromClass(Product);\n * console.log(fields.name.type); // 'TEXT'\n * console.log(fields.price.type); // 'REAL'\n * ```\n */\nexport async function fieldsFromClass(\n ClassType: new (...args: never[]) => SmrtObject,\n values?: Record<string, unknown>,\n) {\n // `getClassByConstructor` expects the registry's `SmrtObjectConstructor`\n // (`new (...args: any[]) => SmrtObject`); the narrower `never[]` param type\n // is contravariantly assignable, so this widening cast is purely structural.\n const registered = ObjectRegistry.getClassByConstructor(\n ClassType as SmrtObjectConstructor,\n );\n // A collection has the exact item constructor even when relation metadata\n // uses a legacy simple name. Keep that constructor's qualified identity for\n // field lookup so a same-named class in another package cannot supply an\n // empty or unrelated field bag.\n const className =\n registered?.qualifiedName &&\n registered.name &&\n ObjectRegistry.findClassesByName(registered.name).length > 1\n ? registered.qualifiedName\n : (registered?.name ?? ClassType.name);\n // NEW: Use getAllFields() to include inherited fields from parent classes\n const cachedFields = await ObjectRegistry.getAllFields(className);\n\n // Phase 2: AST manifest only - no runtime introspection fallback\n if (cachedFields.size === 0) {\n // Return empty fields for unregistered classes (for backward compatibility)\n // generateSchema() will throw if it needs field definitions\n return {};\n }\n\n // Use cached field definitions from AST manifest\n const fields: Record<string, unknown> = {};\n\n // Add/override with fields from cached registry\n for (const [key, field] of cachedFields.entries()) {\n const meta = { ...(field._meta || {}) };\n delete meta.__smrtSystemField;\n\n fields[key] = {\n name: key,\n type: field.type || 'TEXT',\n _meta: meta,\n ...(values && key in values ? { value: values[key] } : {}),\n };\n }\n\n return fields;\n}\n\n// NOTE: generateSchema moved to schema/utils.ts\n// to prevent bundling Node.js-only code (SchemaGenerator with node:crypto) in browser builds.\n// Import from './schema/utils' in Node.js code that needs schema generation.\n\n/**\n * Returns the old (incorrect) pluralized table name for migration purposes.\n *\n * This function replicates the previous buggy behavior where 'y' → 'ies'\n * transformation was applied AFTER adding 's', resulting in incorrect\n * pluralization (e.g., 'currency' → 'currencys' instead of 'currencies').\n *\n * Use this to generate migration SQL that renames old tables to new names.\n *\n * @param className - Name of the class\n * @returns The incorrectly pluralized table name (old behavior)\n * @example\n * ```typescript\n * // Generate migration for a class\n * const oldName = legacyClassnameToTablename('Currency'); // 'currencys'\n * const newName = classnameToTablename('Currency'); // 'currencies'\n * console.log(`ALTER TABLE ${oldName} RENAME TO ${newName};`);\n * ```\n */\nexport function legacyClassnameToTablename(className: string): string {\n return className\n .replace(/([a-z])([A-Z])/g, '$1_$2')\n .toLowerCase()\n .replace(/([^s])$/, '$1s')\n .replace(/y$/, 'ies'); // This runs AFTER 's' is added, so it's a no-op for 'y' words\n}\n\n/**\n * Generates migration SQL for renaming tables from old to new naming convention.\n *\n * Returns an array of SQL statements to rename tables that were affected\n * by the pluralization bug (Issue #839).\n *\n * @param classNames - Array of class names to check for migration\n * @returns Array of SQL RENAME statements (empty if no changes needed)\n * @example\n * ```typescript\n * const migrations = generateTableRenameMigrations([\n * 'Currency',\n * 'JournalEntry',\n * 'Product' // Not affected\n * ]);\n * // Returns:\n * // [\n * // 'ALTER TABLE currencys RENAME TO currencies;',\n * // 'ALTER TABLE journal_entrys RENAME TO journal_entries;'\n * // ]\n * ```\n */\nexport function generateTableRenameMigrations(classNames: string[]): string[] {\n const migrations: string[] = [];\n\n for (const className of classNames) {\n const oldName = legacyClassnameToTablename(className);\n const newName = classnameToTablename(className);\n\n if (oldName !== newName) {\n migrations.push(`ALTER TABLE ${oldName} RENAME TO ${newName};`);\n }\n }\n\n return migrations;\n}\n\n/**\n * Generates a table name from a class constructor\n *\n * Checks for SMRT_TABLE_NAME static property first (set by @smrt() decorator),\n * which survives code minification. Falls back to deriving from ClassType.name\n * for backward compatibility.\n *\n * @param ClassType - Class constructor or function\n * @returns Pluralized snake_case table name\n * @example\n * ```typescript\n * // With @smrt() decorator (recommended)\n * @smrt()\n * class Product extends SmrtObject { }\n * tableNameFromClass(Product); // \"products\" (captured before minification)\n *\n * // Without decorator (fallback)\n * class Category extends SmrtObject { }\n * tableNameFromClass(Category); // \"categories\" (derived from runtime name)\n * ```\n */\nexport function tableNameFromClass(\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n ClassType: Function | (new (...args: never[]) => SmrtObject),\n) {\n // Check for SMRT_TABLE_NAME property set by @smrt() decorator (survives minification)\n if ('SMRT_TABLE_NAME' in ClassType) {\n // The `in` guard above proves the static decorator property exists; read it\n // through a typed shape rather than `any`.\n return (ClassType as { SMRT_TABLE_NAME: string }).SMRT_TABLE_NAME;\n }\n\n // Fallback: derive from class name (breaks with minification)\n const snakeCase = ClassType.name\n // Insert underscore between lower & upper case letters\n .replace(/([a-z])([A-Z])/g, '$1_$2')\n // Convert to lowercase\n .toLowerCase();\n\n return pluralize(snakeCase);\n}\n\n/**\n * Formats data for JavaScript by converting date strings to Date objects\n * and snake_case column names to camelCase properties\n *\n * Generic over the input row type `T` so callers preserve their (typically\n * loose) row typing across the hydration boundary — this matches the historic\n * behavior where a `Record<string, any>` / `any` row produced an equally loose\n * result. Keys are re-cased and values are type-converted at runtime; the\n * return is the same `Record` shape, so it is reasserted as `T` at this DB\n * boundary rather than widened to an unrelated type.\n *\n * @param data - Object with data to format (snake_case column names from DB)\n * @param fields - Optional field definitions to determine types (from fieldsFromClass)\n * @returns Object with properly typed values and camelCase property names for JavaScript\n */\nexport function formatDataJs<\n T extends Record<string, unknown> = Record<string, unknown>,\n>(data: T, fields?: Record<string, { type?: string }>): T {\n const normalizedData: Record<string, unknown> = {};\n\n if (process.env.DEBUG_STI) {\n logger.debug('[formatDataJs] Input data', {\n hasMetaData: !!data._meta_data,\n metaType: data._meta_type,\n keys: Object.keys(data),\n });\n }\n\n // STI: If _meta_data exists, merge it into a FRESH object first so meta\n // fields are available during formatting. This must never mutate the caller's\n // `data` argument — `formatDataJs` is a public export and external callers may\n // pass shared objects that would otherwise get silent field injection (#1378).\n let mergedData: Record<string, unknown> = data;\n if (data._meta_data) {\n // `_meta_data` is the STI meta column: a JSON string from the DB or an\n // already-parsed object. Either way it is an object of meta fields; type it\n // as such at this boundary so the merge/key-walk below stays sound.\n const metaData: Record<string, unknown> =\n typeof data._meta_data === 'string'\n ? JSON.parse(data._meta_data)\n : (data._meta_data as Record<string, unknown>);\n\n if (process.env.DEBUG_STI) {\n logger.debug('[formatDataJs] Merging _meta_data', {\n metaDataKeys: Object.keys(metaData),\n metaData,\n });\n }\n\n // Merge meta fields into a copy (will be formatted below). Spreading\n // `metaData` last preserves the original precedence of Object.assign.\n mergedData = { ...data, ...metaData };\n\n if (process.env.DEBUG_STI) {\n logger.debug('[formatDataJs] After merge, data keys', {\n keys: Object.keys(mergedData),\n });\n }\n }\n\n for (const [key, value] of Object.entries(mergedData)) {\n // Preserve keys with leading underscore (e.g., _meta_type, _meta_data)\n // These are special STI/framework fields that should not be camelCased\n const camelKey = key.startsWith('_') ? key : toCamelCase(key);\n\n // Determine the actual output key based on what's in fields\n // If the original key (snake_case) is in fields, use it; otherwise use camelCase\n // This supports both conventions (e.g., publish_date vs publishDate)\n let outputKey = camelKey;\n if (fields && key in fields && !(camelKey in fields)) {\n // Original key exists in fields but camelCase doesn't - use original (snake_case)\n outputKey = key;\n }\n\n // Get field type from fields, trying both key variants\n const fieldDef = fields?.[outputKey] ?? fields?.[camelKey] ?? fields?.[key];\n const fieldType = fieldDef?.type?.toLowerCase();\n\n if (value instanceof Date) {\n normalizedData[outputKey] = value;\n } else if (typeof value === 'string') {\n // Use field definitions if available, otherwise fall back to name patterns\n const isDate = fieldType === 'datetime' || isDateField(key);\n\n if (isDate) {\n const parsedDate = value.trim() ? new Date(value) : null;\n normalizedData[outputKey] =\n parsedDate && !Number.isNaN(parsedDate.getTime())\n ? parsedDate\n : value;\n } else if (fieldType === 'json') {\n // Parse JSON strings back to objects\n try {\n normalizedData[outputKey] = JSON.parse(value);\n } catch {\n // Keep as string if parsing fails\n normalizedData[outputKey] = value;\n }\n } else if (fieldType === 'integer') {\n // PostgreSQL returns int8 as strings. SQLite may spell an integer as\n // \"2.0\"; reject values JavaScript cannot represent exactly rather than\n // rounding them while hydrating a model.\n const parsed = Number(value);\n normalizedData[outputKey] =\n Number.isFinite(parsed) && Number.isInteger(parsed)\n ? toSafeInteger(value, `Integer field ${outputKey}`)\n : value;\n } else if (fieldType === 'real' || fieldType === 'decimal') {\n // Convert string numbers to floats for REAL/DECIMAL fields\n const parsed = Number.parseFloat(value);\n normalizedData[outputKey] = Number.isNaN(parsed) ? value : parsed;\n } else {\n normalizedData[outputKey] = value;\n }\n } else if (typeof value === 'number') {\n if (fieldType === 'boolean') {\n // Convert SQLite integers (0/1) to booleans for boolean fields\n normalizedData[outputKey] = value === 1;\n } else if (fieldType === 'integer' && Number.isInteger(value)) {\n normalizedData[outputKey] = toSafeInteger(\n value,\n `Integer field ${outputKey}`,\n );\n } else {\n // Pass through numeric values as-is\n // Note: In JavaScript, 2.0 === 2 so no conversion needed\n // Non-integers in INTEGER fields (e.g., 2.9) are kept to surface data issues\n normalizedData[outputKey] = value;\n }\n } else if (typeof value === 'bigint' && fieldType === 'integer') {\n normalizedData[outputKey] = toSafeInteger(\n value,\n `Integer field ${outputKey}`,\n );\n } else {\n normalizedData[outputKey] = value;\n }\n }\n\n if (process.env.DEBUG_STI) {\n logger.debug('[formatDataJs] Output normalizedData', {\n keys: Object.keys(normalizedData),\n metaType: normalizedData._meta_type,\n });\n }\n\n // Reassert the re-cased/type-converted row as the caller's row type `T`.\n // The output is structurally a `Record<string, unknown>`; this boundary cast\n // preserves the historic loose-in/loose-out hydration contract without `any`.\n return normalizedData as T;\n}\n\n/**\n * Type guard to check if a value is a Field instance\n *\n * @deprecated Field helpers have been removed. This function always returns false.\n * @param value - Value to check\n * @returns Always false (Field class no longer exists)\n */\nexport function isFieldInstance(value: unknown): value is never {\n return false;\n}\n\n/**\n * Formats data for SQL by converting Date objects to ISO strings\n * and camelCase property names to snake_case column names\n *\n * @param data - Object with data to format (camelCase property names from JavaScript)\n * @returns Object with properly formatted values and snake_case column names for SQL\n */\nexport function formatDataSql(data: Record<string, unknown>) {\n const normalizedData: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n // Convert camelCase to snake_case for SQL\n const snakeKey = toSnakeCase(key);\n\n // Field helpers removed - no need to extract values (deprecated code removed)\n if (value instanceof Date) {\n normalizedData[snakeKey] = value.toISOString(); // Postgres accepts ISO format with timezone\n } else {\n normalizedData[snakeKey] = value;\n }\n }\n return normalizedData;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import { createLogger } from '@happyvertical/logger';\nimport type { SmrtObject } from './object';\nimport { ObjectRegistry } from './registry';\nimport type { SmrtObjectConstructor } from './registry/types';\nimport {\n classnameToTablename,\n pluralize,\n toSnakeCase,\n} from './utils/naming.js';\nimport { toSafeBooleanInteger, toSafeInteger } from './utils/safe-integer.js';\n\n// formatDataJs' debug traces are gated by DEBUG_STI, so the level must allow\n// debug when it's set (a fixed 'info' would filter them out and break the flag).\nconst logger = createLogger({\n level: process.env.DEBUG_STI ? 'debug' : 'info',\n});\n\nexport {\n classnameToTablename,\n pluralize,\n toSafeBooleanInteger,\n toSafeInteger,\n toSnakeCase,\n};\n\n/**\n * Converts a snake_case string to camelCase\n *\n * @param str - String in snake_case format\n * @returns String in camelCase format\n * @example\n * ```typescript\n * toCamelCase('meetings_url'); // 'meetingsUrl'\n * toCamelCase('created_at'); // 'createdAt'\n * toCamelCase('id'); // 'id'\n * ```\n */\nexport function toCamelCase(str: string): string {\n return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());\n}\n\n/**\n * Converts all keys in an object from camelCase to snake_case\n *\n * @param obj - Object with camelCase keys\n * @returns Object with snake_case keys\n */\nexport function keysToSnakeCase(\n obj: Record<string, unknown>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n result[toSnakeCase(key)] = value;\n }\n return result;\n}\n\n/**\n * Converts all keys in an object from snake_case to camelCase\n *\n * @param obj - Object with snake_case keys\n * @returns Object with camelCase keys\n */\nexport function keysToCamelCase(\n obj: Record<string, unknown>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n result[toCamelCase(key)] = value;\n }\n return result;\n}\n\n/**\n * Checks if a field name indicates a date field based on naming conventions\n *\n * Recognizes common date field patterns like '_at', '_date', and 'date'.\n * Used for automatic type inference during schema generation.\n *\n * @param key - Field name to check\n * @returns Boolean indicating if the field is likely a date field\n * @example\n * ```typescript\n * isDateField('created_at'); // true\n * isDateField('updated_date'); // true\n * isDateField('name'); // false\n * ```\n */\nexport function isDateField(key: string) {\n // Fallback pattern matching when schema is not available\n // Primary date detection should use schema field types\n return key.endsWith('_date') || key.endsWith('_at') || key === 'date';\n}\n\n/**\n * Converts a date string to a Date object\n *\n * @param date - Date as string or Date object\n * @returns Date object\n */\nexport function dateAsString(date: Date | string) {\n if (typeof date === 'string') {\n return new Date(date);\n }\n return date;\n}\n\n/**\n * Converts a Date object to an ISO string\n *\n * @param date - Date as Date object or string\n * @returns ISO date string or the original string\n */\nexport function dateAsObject(date: Date | string) {\n if (date instanceof Date) {\n return date.toISOString();\n }\n return date;\n}\n\n/**\n * Extracts field definitions from a class constructor\n *\n * Uses ObjectRegistry cached fields from AST manifest exclusively.\n * No runtime introspection fallback - classes must be decorated with @smrt()\n * for schema generation to work.\n *\n * @param ClassType - Class constructor to extract fields from\n * @param values - Optional values to set for the fields\n * @returns Object containing field definitions with names, types, and values\n * @throws {Error} If the class is not registered in ObjectRegistry\n * @example\n * ```typescript\n * @smrt()\n * class Product extends SmrtObject {\n * name: string = '';\n * price: number = 0.0;\n * }\n *\n * const fields = await fieldsFromClass(Product);\n * console.log(fields.name.type); // 'TEXT'\n * console.log(fields.price.type); // 'REAL'\n * ```\n */\nexport async function fieldsFromClass(\n ClassType: new (...args: never[]) => SmrtObject,\n values?: Record<string, unknown>,\n) {\n // `getClassByConstructor` expects the registry's `SmrtObjectConstructor`\n // (`new (...args: any[]) => SmrtObject`); the narrower `never[]` param type\n // is contravariantly assignable, so this widening cast is purely structural.\n const registered = ObjectRegistry.getClassByConstructor(\n ClassType as SmrtObjectConstructor,\n );\n // A collection has the exact item constructor even when relation metadata\n // uses a legacy simple name. Keep that constructor's qualified identity for\n // field lookup so a same-named class in another package cannot supply an\n // empty or unrelated field bag.\n const className =\n registered?.qualifiedName &&\n registered.name &&\n ObjectRegistry.findClassesByName(registered.name).length > 1\n ? registered.qualifiedName\n : (registered?.name ?? ClassType.name);\n // NEW: Use getAllFields() to include inherited fields from parent classes\n const cachedFields = await ObjectRegistry.getAllFields(className);\n\n // Phase 2: AST manifest only - no runtime introspection fallback\n if (cachedFields.size === 0) {\n // Return empty fields for unregistered classes (for backward compatibility)\n // generateSchema() will throw if it needs field definitions\n return {};\n }\n\n // Use cached field definitions from AST manifest\n const fields: Record<string, unknown> = {};\n\n // Add/override with fields from cached registry\n for (const [key, field] of cachedFields.entries()) {\n const meta = { ...(field._meta || {}) };\n delete meta.__smrtSystemField;\n\n fields[key] = {\n name: key,\n type: field.type || 'TEXT',\n _meta: meta,\n ...(values && key in values ? { value: values[key] } : {}),\n };\n }\n\n return fields;\n}\n\n// NOTE: generateSchema moved to schema/utils.ts\n// to prevent bundling Node.js-only code (SchemaGenerator with node:crypto) in browser builds.\n// Import from './schema/utils' in Node.js code that needs schema generation.\n\n/**\n * Returns the old (incorrect) pluralized table name for migration purposes.\n *\n * This function replicates the previous buggy behavior where 'y' → 'ies'\n * transformation was applied AFTER adding 's', resulting in incorrect\n * pluralization (e.g., 'currency' → 'currencys' instead of 'currencies').\n *\n * Use this to generate migration SQL that renames old tables to new names.\n *\n * @param className - Name of the class\n * @returns The incorrectly pluralized table name (old behavior)\n * @example\n * ```typescript\n * // Generate migration for a class\n * const oldName = legacyClassnameToTablename('Currency'); // 'currencys'\n * const newName = classnameToTablename('Currency'); // 'currencies'\n * console.log(`ALTER TABLE ${oldName} RENAME TO ${newName};`);\n * ```\n */\nexport function legacyClassnameToTablename(className: string): string {\n return className\n .replace(/([a-z])([A-Z])/g, '$1_$2')\n .toLowerCase()\n .replace(/([^s])$/, '$1s')\n .replace(/y$/, 'ies'); // This runs AFTER 's' is added, so it's a no-op for 'y' words\n}\n\n/**\n * Generates migration SQL for renaming tables from old to new naming convention.\n *\n * Returns an array of SQL statements to rename tables that were affected\n * by the pluralization bug (Issue #839).\n *\n * @param classNames - Array of class names to check for migration\n * @returns Array of SQL RENAME statements (empty if no changes needed)\n * @example\n * ```typescript\n * const migrations = generateTableRenameMigrations([\n * 'Currency',\n * 'JournalEntry',\n * 'Product' // Not affected\n * ]);\n * // Returns:\n * // [\n * // 'ALTER TABLE currencys RENAME TO currencies;',\n * // 'ALTER TABLE journal_entrys RENAME TO journal_entries;'\n * // ]\n * ```\n */\nexport function generateTableRenameMigrations(classNames: string[]): string[] {\n const migrations: string[] = [];\n\n for (const className of classNames) {\n const oldName = legacyClassnameToTablename(className);\n const newName = classnameToTablename(className);\n\n if (oldName !== newName) {\n migrations.push(`ALTER TABLE ${oldName} RENAME TO ${newName};`);\n }\n }\n\n return migrations;\n}\n\n/**\n * Generates a table name from a class constructor\n *\n * Checks for SMRT_TABLE_NAME static property first (set by @smrt() decorator),\n * which survives code minification. Falls back to deriving from ClassType.name\n * for backward compatibility.\n *\n * @param ClassType - Class constructor or function\n * @returns Pluralized snake_case table name\n * @example\n * ```typescript\n * // With @smrt() decorator (recommended)\n * @smrt()\n * class Product extends SmrtObject { }\n * tableNameFromClass(Product); // \"products\" (captured before minification)\n *\n * // Without decorator (fallback)\n * class Category extends SmrtObject { }\n * tableNameFromClass(Category); // \"categories\" (derived from runtime name)\n * ```\n */\nexport function tableNameFromClass(\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n ClassType: Function | (new (...args: never[]) => SmrtObject),\n) {\n // Check for SMRT_TABLE_NAME property set by @smrt() decorator (survives minification)\n if ('SMRT_TABLE_NAME' in ClassType) {\n // The `in` guard above proves the static decorator property exists; read it\n // through a typed shape rather than `any`.\n return (ClassType as { SMRT_TABLE_NAME: string }).SMRT_TABLE_NAME;\n }\n\n // Fallback: derive from class name (breaks with minification)\n const snakeCase = ClassType.name\n // Insert underscore between lower & upper case letters\n .replace(/([a-z])([A-Z])/g, '$1_$2')\n // Convert to lowercase\n .toLowerCase();\n\n return pluralize(snakeCase);\n}\n\n/**\n * Keys that may never be read from (or written to) the STI `_meta_data` blob\n * (#3238): framework `_`-prefixed keys, the primary key, and tenant-ownership\n * fields. These identities live in real columns only; a meta key of the same\n * name is untrusted payload and is dropped rather than allowed to shadow them.\n *\n * @param key - A `_meta_data` key (camelCase or snake_case)\n * @param fields - Optional field definitions; any field carrying a tenancy\n * marker (`__tenancy.isTenantIdField`) is reserved under its own name too\n */\nexport function isReservedMetaDataKey(\n key: string,\n fields?: Record<string, unknown>,\n): boolean {\n if (key.startsWith('_')) return true;\n const camelKey = toCamelCase(key);\n if (camelKey === 'id' || camelKey === 'tenantId') return true;\n const field = (fields?.[camelKey] ?? fields?.[key]) as\n | {\n __tenancy?: { isTenantIdField?: boolean };\n _meta?: { __tenancy?: { isTenantIdField?: boolean } };\n }\n | undefined;\n return Boolean(\n field?.__tenancy?.isTenantIdField ||\n field?._meta?.__tenancy?.isTenantIdField,\n );\n}\n\n/**\n * Returns the STI `_meta_data` value with every reserved key\n * (`isReservedMetaDataKey`) removed, in the representation it arrived in: an\n * object stays an object, a JSON string is re-serialized (#3238). A value with\n * no reserved key, or one that is not a JSON object, is returned unchanged.\n *\n * @param metaData - `_meta_data` as stored (JSON text) or already parsed\n * @param fields - Optional field definitions (see `isReservedMetaDataKey`)\n */\nexport function sanitizeMetaData(\n metaData: unknown,\n fields?: Record<string, unknown>,\n): unknown {\n let parsed: unknown = metaData;\n if (typeof metaData === 'string') {\n try {\n parsed = JSON.parse(metaData);\n } catch {\n return metaData;\n }\n }\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return metaData;\n }\n const entries = Object.entries(parsed as Record<string, unknown>);\n const kept = entries.filter(([key]) => !isReservedMetaDataKey(key, fields));\n if (kept.length === entries.length) return metaData;\n const sanitized = Object.fromEntries(kept);\n return typeof metaData === 'string' ? JSON.stringify(sanitized) : sanitized;\n}\n\n/**\n * Formats data for JavaScript by converting date strings to Date objects\n * and snake_case column names to camelCase properties\n *\n * Generic over the input row type `T` so callers preserve their (typically\n * loose) row typing across the hydration boundary — this matches the historic\n * behavior where a `Record<string, any>` / `any` row produced an equally loose\n * result. Keys are re-cased and values are type-converted at runtime; the\n * return is the same `Record` shape, so it is reasserted as `T` at this DB\n * boundary rather than widened to an unrelated type.\n *\n * @param data - Object with data to format (snake_case column names from DB)\n * @param fields - Optional field definitions to determine types (from fieldsFromClass)\n * @returns Object with properly typed values and camelCase property names for JavaScript\n */\nexport function formatDataJs<\n T extends Record<string, unknown> = Record<string, unknown>,\n>(data: T, fields?: Record<string, { type?: string }>): T {\n const normalizedData: Record<string, unknown> = {};\n\n if (process.env.DEBUG_STI) {\n logger.debug('[formatDataJs] Input data', {\n hasMetaData: !!data._meta_data,\n metaType: data._meta_type,\n keys: Object.keys(data),\n });\n }\n\n // STI: If _meta_data exists, merge it into a FRESH object first so meta\n // fields are available during formatting. This must never mutate the caller's\n // `data` argument — `formatDataJs` is a public export and external callers may\n // pass shared objects that would otherwise get silent field injection (#1378).\n let mergedData: Record<string, unknown> = data;\n if (data._meta_data) {\n // `_meta_data` is the STI meta column: a JSON string from the DB or an\n // already-parsed object. Either way it is an object of meta fields; type it\n // as such at this boundary so the merge/key-walk below stays sound.\n const metaData: Record<string, unknown> =\n typeof data._meta_data === 'string'\n ? JSON.parse(data._meta_data)\n : (data._meta_data as Record<string, unknown>);\n\n if (process.env.DEBUG_STI) {\n logger.debug('[formatDataJs] Merging _meta_data', {\n metaDataKeys: Object.keys(metaData),\n metaData,\n });\n }\n\n // Merge meta fields into a copy (will be formatted below). Declared\n // columns win (#3238): a `_meta_data` key never shadows a column the row\n // carries, so `{ tenantId: <other tenant> }` in the meta blob cannot\n // replace the row's real `tenant_id` (or `id`, `_meta_type`, ...).\n // The retained blob is sanitized too, so code reading `_meta_data` off\n // the hydrated row never sees a forged `id` / tenant / `_meta_*` key.\n mergedData = {\n ...data,\n _meta_data: sanitizeMetaData(data._meta_data, fields),\n };\n const columnKeys = new Set(\n Object.keys(data).map((key) =>\n key.startsWith('_') ? key : toCamelCase(key),\n ),\n );\n for (const [key, value] of Object.entries(metaData)) {\n if (isReservedMetaDataKey(key, fields)) continue;\n const camelKey = toCamelCase(key);\n if (columnKeys.has(camelKey)) {\n // An STI child's own `@meta()` field may share its name with a\n // sibling's real column. That column belongs to the sibling (NULL or\n // its DDL default on this child's rows), so the field this class\n // declares as meta takes the meta value. Otherwise the column wins.\n const fieldType = (fields?.[camelKey] ?? fields?.[key])?.type;\n if (fieldType !== 'meta') continue;\n for (const column of Object.keys(data)) {\n if (toCamelCase(column) === camelKey) delete mergedData[column];\n }\n }\n mergedData[key] = value;\n }\n\n if (process.env.DEBUG_STI) {\n logger.debug('[formatDataJs] After merge, data keys', {\n keys: Object.keys(mergedData),\n });\n }\n }\n\n for (const [key, value] of Object.entries(mergedData)) {\n // Preserve keys with leading underscore (e.g., _meta_type, _meta_data)\n // These are special STI/framework fields that should not be camelCased\n const camelKey = key.startsWith('_') ? key : toCamelCase(key);\n\n // Determine the actual output key based on what's in fields\n // If the original key (snake_case) is in fields, use it; otherwise use camelCase\n // This supports both conventions (e.g., publish_date vs publishDate)\n let outputKey = camelKey;\n if (fields && key in fields && !(camelKey in fields)) {\n // Original key exists in fields but camelCase doesn't - use original (snake_case)\n outputKey = key;\n }\n\n // Get field type from fields, trying both key variants\n const fieldDef = fields?.[outputKey] ?? fields?.[camelKey] ?? fields?.[key];\n const fieldType = fieldDef?.type?.toLowerCase();\n\n if (value instanceof Date) {\n normalizedData[outputKey] = value;\n } else if (typeof value === 'string') {\n // Use field definitions if available, otherwise fall back to name patterns\n const isDate = fieldType === 'datetime' || isDateField(key);\n\n if (isDate) {\n const parsedDate = value.trim() ? new Date(value) : null;\n normalizedData[outputKey] =\n parsedDate && !Number.isNaN(parsedDate.getTime())\n ? parsedDate\n : value;\n } else if (fieldType === 'json') {\n // Parse JSON strings back to objects\n try {\n normalizedData[outputKey] = JSON.parse(value);\n } catch {\n // Keep as string if parsing fails\n normalizedData[outputKey] = value;\n }\n } else if (fieldType === 'integer') {\n // PostgreSQL returns int8 as strings. SQLite may spell an integer as\n // \"2.0\"; reject values JavaScript cannot represent exactly rather than\n // rounding them while hydrating a model.\n const parsed = Number(value);\n normalizedData[outputKey] =\n Number.isFinite(parsed) && Number.isInteger(parsed)\n ? toSafeInteger(value, `Integer field ${outputKey}`)\n : value;\n } else if (fieldType === 'real' || fieldType === 'decimal') {\n // Convert string numbers to floats for REAL/DECIMAL fields\n const parsed = Number.parseFloat(value);\n normalizedData[outputKey] = Number.isNaN(parsed) ? value : parsed;\n } else {\n normalizedData[outputKey] = value;\n }\n } else if (typeof value === 'number') {\n if (fieldType === 'boolean') {\n // Convert SQLite integers (0/1) to booleans for boolean fields\n normalizedData[outputKey] = value === 1;\n } else if (fieldType === 'integer' && Number.isInteger(value)) {\n normalizedData[outputKey] = toSafeInteger(\n value,\n `Integer field ${outputKey}`,\n );\n } else {\n // Pass through numeric values as-is\n // Note: In JavaScript, 2.0 === 2 so no conversion needed\n // Non-integers in INTEGER fields (e.g., 2.9) are kept to surface data issues\n normalizedData[outputKey] = value;\n }\n } else if (typeof value === 'bigint' && fieldType === 'integer') {\n normalizedData[outputKey] = toSafeInteger(\n value,\n `Integer field ${outputKey}`,\n );\n } else {\n normalizedData[outputKey] = value;\n }\n }\n\n if (process.env.DEBUG_STI) {\n logger.debug('[formatDataJs] Output normalizedData', {\n keys: Object.keys(normalizedData),\n metaType: normalizedData._meta_type,\n });\n }\n\n // Reassert the re-cased/type-converted row as the caller's row type `T`.\n // The output is structurally a `Record<string, unknown>`; this boundary cast\n // preserves the historic loose-in/loose-out hydration contract without `any`.\n return normalizedData as T;\n}\n\n/**\n * Type guard to check if a value is a Field instance\n *\n * @deprecated Field helpers have been removed. This function always returns false.\n * @param value - Value to check\n * @returns Always false (Field class no longer exists)\n */\nexport function isFieldInstance(value: unknown): value is never {\n return false;\n}\n\n/**\n * Formats data for SQL by converting Date objects to ISO strings\n * and camelCase property names to snake_case column names\n *\n * @param data - Object with data to format (camelCase property names from JavaScript)\n * @returns Object with properly formatted values and snake_case column names for SQL\n */\nexport function formatDataSql(data: Record<string, unknown>) {\n const normalizedData: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n // Convert camelCase to snake_case for SQL\n const snakeKey = toSnakeCase(key);\n\n // Field helpers removed - no need to extract values (deprecated code removed)\n if (value instanceof Date) {\n normalizedData[snakeKey] = value.toISOString(); // Postgres accepts ISO format with timezone\n } else {\n normalizedData[snakeKey] = value;\n }\n }\n return normalizedData;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-core",
3
- "version": "0.55.12",
3
+ "version": "0.55.13",
4
4
  "description": "Core AI agent framework with standardized collections, object-relational mapping, and code generators",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -185,15 +185,15 @@
185
185
  "./dist/utils/json.js": "./dist/utils/json.browser.js"
186
186
  },
187
187
  "dependencies": {
188
- "@happyvertical/ai": "^0.103.1",
189
- "@happyvertical/files": "^0.103.1",
190
- "@happyvertical/json": "^0.103.1",
191
- "@happyvertical/logger": "^0.103.1",
192
- "@happyvertical/smrt-config": "0.55.12",
193
- "@happyvertical/smrt-scanner": "0.55.12",
194
- "@happyvertical/smrt-types": "0.55.12",
195
- "@happyvertical/sql": "^0.103.1",
196
- "@happyvertical/utils": "^0.103.1",
188
+ "@happyvertical/ai": "^0.103.2",
189
+ "@happyvertical/files": "^0.103.2",
190
+ "@happyvertical/json": "^0.103.2",
191
+ "@happyvertical/logger": "^0.103.2",
192
+ "@happyvertical/smrt-config": "0.55.13",
193
+ "@happyvertical/smrt-scanner": "0.55.13",
194
+ "@happyvertical/smrt-types": "0.55.13",
195
+ "@happyvertical/sql": "^0.103.2",
196
+ "@happyvertical/utils": "^0.103.2",
197
197
  "@noble/hashes": "^2.4.0",
198
198
  "@oxc-project/runtime": "^0.150.0",
199
199
  "cheerio": "^1.2.0",