@happyvertical/smrt-core 0.30.0
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 +124 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +265 -0
- package/bin/smrt-prebuild.js +26 -0
- package/dist/__tests__/fixtures/advisor-test-classes.d.ts +28 -0
- package/dist/__tests__/fixtures/advisor-test-classes.d.ts.map +1 -0
- package/dist/__tests__/fixtures/collection-coverage-fixtures.d.ts +12 -0
- package/dist/__tests__/fixtures/collection-coverage-fixtures.d.ts.map +1 -0
- package/dist/__tests__/fixtures/inheritance-resolver-fixtures.d.ts +28 -0
- package/dist/__tests__/fixtures/inheritance-resolver-fixtures.d.ts.map +1 -0
- package/dist/__tests__/fixtures/inheritance-test-classes.d.ts +43 -0
- package/dist/__tests__/fixtures/inheritance-test-classes.d.ts.map +1 -0
- package/dist/__tests__/fixtures/mcp-integration-test-classes.d.ts +18 -0
- package/dist/__tests__/fixtures/mcp-integration-test-classes.d.ts.map +1 -0
- package/dist/__tests__/fixtures/object-ai-memory-fixtures.d.ts +15 -0
- package/dist/__tests__/fixtures/object-ai-memory-fixtures.d.ts.map +1 -0
- package/dist/__tests__/fixtures/object-spec-test-classes.d.ts +13 -0
- package/dist/__tests__/fixtures/object-spec-test-classes.d.ts.map +1 -0
- package/dist/__tests__/fixtures/registry-test-classes.d.ts +23 -0
- package/dist/__tests__/fixtures/registry-test-classes.d.ts.map +1 -0
- package/dist/adapters/ai-usage.d.ts +23 -0
- package/dist/adapters/ai-usage.d.ts.map +1 -0
- package/dist/adapters/ai-usage.js +105 -0
- package/dist/adapters/ai-usage.js.map +1 -0
- package/dist/adapters/cost-rates.d.ts +20 -0
- package/dist/adapters/cost-rates.d.ts.map +1 -0
- package/dist/adapters/cost-rates.js +40 -0
- package/dist/adapters/cost-rates.js.map +1 -0
- package/dist/adapters/index.d.ts +19 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/metrics.d.ts +111 -0
- package/dist/adapters/metrics.d.ts.map +1 -0
- package/dist/adapters/metrics.js +169 -0
- package/dist/adapters/metrics.js.map +1 -0
- package/dist/adapters/pubsub.d.ts +124 -0
- package/dist/adapters/pubsub.d.ts.map +1 -0
- package/dist/adapters/pubsub.js +121 -0
- package/dist/adapters/pubsub.js.map +1 -0
- package/dist/browser.d.ts +32 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +68 -0
- package/dist/browser.js.map +1 -0
- package/dist/child-accessors.d.ts +27 -0
- package/dist/child-accessors.d.ts.map +1 -0
- package/dist/child-accessors.js +35 -0
- package/dist/child-accessors.js.map +1 -0
- package/dist/class.d.ts +313 -0
- package/dist/class.d.ts.map +1 -0
- package/dist/class.js +896 -0
- package/dist/class.js.map +1 -0
- package/dist/collection-cache.d.ts +110 -0
- package/dist/collection-cache.d.ts.map +1 -0
- package/dist/collection-cache.js +187 -0
- package/dist/collection-cache.js.map +1 -0
- package/dist/collection.d.ts +894 -0
- package/dist/collection.d.ts.map +1 -0
- package/dist/collection.js +1987 -0
- package/dist/collection.js.map +1 -0
- package/dist/config/global-config.d.ts +3 -0
- package/dist/config/global-config.d.ts.map +1 -0
- package/dist/config/global-config.js +19 -0
- package/dist/config/global-config.js.map +1 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +57 -0
- package/dist/config.js.map +1 -0
- package/dist/consumer-plugin/index.d.ts +22 -0
- package/dist/consumer-plugin/index.d.ts.map +1 -0
- package/dist/consumer-plugin/index.js +452 -0
- package/dist/consumer-plugin/index.js.map +1 -0
- package/dist/consumer-plugin.d.ts +8 -0
- package/dist/consumer-plugin.d.ts.map +1 -0
- package/dist/consumer-plugin.js +5 -0
- package/dist/consumer-plugin.js.map +1 -0
- package/dist/database.d.ts +95 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +32 -0
- package/dist/database.js.map +1 -0
- package/dist/decorators/compatibility.d.ts +14 -0
- package/dist/decorators/compatibility.d.ts.map +1 -0
- package/dist/decorators/compatibility.js +111 -0
- package/dist/decorators/compatibility.js.map +1 -0
- package/dist/decorators/index.d.ts +381 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/index.js +104 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/dispatch/bus.d.ts +306 -0
- package/dist/dispatch/bus.d.ts.map +1 -0
- package/dist/dispatch/bus.js +583 -0
- package/dist/dispatch/bus.js.map +1 -0
- package/dist/dispatch/collections/DispatchSubscriptions.d.ts +79 -0
- package/dist/dispatch/collections/DispatchSubscriptions.d.ts.map +1 -0
- package/dist/dispatch/collections/DispatchSubscriptions.js +243 -0
- package/dist/dispatch/collections/DispatchSubscriptions.js.map +1 -0
- package/dist/dispatch/collections/Dispatches.d.ts +98 -0
- package/dist/dispatch/collections/Dispatches.d.ts.map +1 -0
- package/dist/dispatch/collections/Dispatches.js +358 -0
- package/dist/dispatch/collections/Dispatches.js.map +1 -0
- package/dist/dispatch/index.d.ts +47 -0
- package/dist/dispatch/index.d.ts.map +1 -0
- package/dist/dispatch/models/Dispatch.d.ts +101 -0
- package/dist/dispatch/models/Dispatch.d.ts.map +1 -0
- package/dist/dispatch/models/Dispatch.js +162 -0
- package/dist/dispatch/models/Dispatch.js.map +1 -0
- package/dist/dispatch/models/DispatchSubscription.d.ts +83 -0
- package/dist/dispatch/models/DispatchSubscription.d.ts.map +1 -0
- package/dist/dispatch/models/DispatchSubscription.js +112 -0
- package/dist/dispatch/models/DispatchSubscription.js.map +1 -0
- package/dist/dispatch/tenant-resolver.d.ts +98 -0
- package/dist/dispatch/tenant-resolver.d.ts.map +1 -0
- package/dist/dispatch/tenant-resolver.js +32 -0
- package/dist/dispatch/tenant-resolver.js.map +1 -0
- package/dist/dispatch/types.d.ts +149 -0
- package/dist/dispatch/types.d.ts.map +1 -0
- package/dist/embeddings/hash.d.ts +33 -0
- package/dist/embeddings/hash.d.ts.map +1 -0
- package/dist/embeddings/hash.js +37 -0
- package/dist/embeddings/hash.js.map +1 -0
- package/dist/embeddings/index.d.ts +36 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/provider.d.ts +75 -0
- package/dist/embeddings/provider.d.ts.map +1 -0
- package/dist/embeddings/provider.js +170 -0
- package/dist/embeddings/provider.js.map +1 -0
- package/dist/embeddings/similarity.d.ts +47 -0
- package/dist/embeddings/similarity.d.ts.map +1 -0
- package/dist/embeddings/similarity.js +64 -0
- package/dist/embeddings/similarity.js.map +1 -0
- package/dist/embeddings/storage.d.ts +125 -0
- package/dist/embeddings/storage.d.ts.map +1 -0
- package/dist/embeddings/storage.js +283 -0
- package/dist/embeddings/storage.js.map +1 -0
- package/dist/embeddings/types.d.ts +250 -0
- package/dist/embeddings/types.d.ts.map +1 -0
- package/dist/errors.d.ts +363 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +669 -0
- package/dist/errors.js.map +1 -0
- package/dist/generators/cli.d.ts +162 -0
- package/dist/generators/cli.d.ts.map +1 -0
- package/dist/generators/cli.js +462 -0
- package/dist/generators/cli.js.map +1 -0
- package/dist/generators/index.d.ts +13 -0
- package/dist/generators/index.d.ts.map +1 -0
- package/dist/generators/mcp-runtime-template.d.ts +60 -0
- package/dist/generators/mcp-runtime-template.d.ts.map +1 -0
- package/dist/generators/mcp-runtime-template.js +509 -0
- package/dist/generators/mcp-runtime-template.js.map +1 -0
- package/dist/generators/mcp.d.ts +231 -0
- package/dist/generators/mcp.d.ts.map +1 -0
- package/dist/generators/mcp.js +1220 -0
- package/dist/generators/mcp.js.map +1 -0
- package/dist/generators/rest.d.ts +171 -0
- package/dist/generators/rest.d.ts.map +1 -0
- package/dist/generators/rest.js +591 -0
- package/dist/generators/rest.js.map +1 -0
- package/dist/generators/swagger.d.ts +21 -0
- package/dist/generators/swagger.d.ts.map +1 -0
- package/dist/generators/swagger.js +307 -0
- package/dist/generators/swagger.js.map +1 -0
- package/dist/generators/tenant-gate.d.ts +74 -0
- package/dist/generators/tenant-gate.d.ts.map +1 -0
- package/dist/generators/tenant-gate.js +15 -0
- package/dist/generators/tenant-gate.js.map +1 -0
- package/dist/generators.d.ts +8 -0
- package/dist/generators.d.ts.map +1 -0
- package/dist/generators.js +19 -0
- package/dist/generators.js.map +1 -0
- package/dist/hierarchical.d.ts +103 -0
- package/dist/hierarchical.d.ts.map +1 -0
- package/dist/hierarchical.js +184 -0
- package/dist/hierarchical.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +202 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptors.d.ts +251 -0
- package/dist/interceptors.d.ts.map +1 -0
- package/dist/interceptors.js +259 -0
- package/dist/interceptors.js.map +1 -0
- package/dist/junction.d.ts +99 -0
- package/dist/junction.d.ts.map +1 -0
- package/dist/junction.js +136 -0
- package/dist/junction.js.map +1 -0
- package/dist/knowledge.d.ts +11 -0
- package/dist/knowledge.d.ts.map +1 -0
- package/dist/knowledge.js +310 -0
- package/dist/knowledge.js.map +1 -0
- package/dist/lazy-config.d.ts +160 -0
- package/dist/lazy-config.d.ts.map +1 -0
- package/dist/lazy-config.js +146 -0
- package/dist/lazy-config.js.map +1 -0
- package/dist/manifest/discover-base-classes.d.ts +78 -0
- package/dist/manifest/discover-base-classes.d.ts.map +1 -0
- package/dist/manifest/discover-base-classes.js +85 -0
- package/dist/manifest/discover-base-classes.js.map +1 -0
- package/dist/manifest/discover-smrt-packages.d.ts +48 -0
- package/dist/manifest/discover-smrt-packages.d.ts.map +1 -0
- package/dist/manifest/discover-smrt-packages.js +361 -0
- package/dist/manifest/discover-smrt-packages.js.map +1 -0
- package/dist/manifest/generator.d.ts +93 -0
- package/dist/manifest/generator.d.ts.map +1 -0
- package/dist/manifest/generator.js +380 -0
- package/dist/manifest/generator.js.map +1 -0
- package/dist/manifest/index.d.ts +16 -0
- package/dist/manifest/index.d.ts.map +1 -0
- package/dist/manifest/index.js +51 -0
- package/dist/manifest/index.js.map +1 -0
- package/dist/manifest/manager.d.ts +51 -0
- package/dist/manifest/manager.d.ts.map +1 -0
- package/dist/manifest/manager.js +89 -0
- package/dist/manifest/manager.js.map +1 -0
- package/dist/manifest/manifest-loader.d.ts +187 -0
- package/dist/manifest/manifest-loader.d.ts.map +1 -0
- package/dist/manifest/manifest-loader.js +847 -0
- package/dist/manifest/manifest-loader.js.map +1 -0
- package/dist/manifest/sources/composite.d.ts +22 -0
- package/dist/manifest/sources/composite.d.ts.map +1 -0
- package/dist/manifest/sources/composite.js +60 -0
- package/dist/manifest/sources/composite.js.map +1 -0
- package/dist/manifest/sources/embedded.d.ts +7 -0
- package/dist/manifest/sources/embedded.d.ts.map +1 -0
- package/dist/manifest/sources/embedded.js +30 -0
- package/dist/manifest/sources/embedded.js.map +1 -0
- package/dist/manifest/sources/explicit-paths.d.ts +17 -0
- package/dist/manifest/sources/explicit-paths.d.ts.map +1 -0
- package/dist/manifest/sources/explicit-paths.js +35 -0
- package/dist/manifest/sources/explicit-paths.js.map +1 -0
- package/dist/manifest/sources/fallback.d.ts +25 -0
- package/dist/manifest/sources/fallback.d.ts.map +1 -0
- package/dist/manifest/sources/fallback.js +63 -0
- package/dist/manifest/sources/fallback.js.map +1 -0
- package/dist/manifest/sources/index.d.ts +17 -0
- package/dist/manifest/sources/index.d.ts.map +1 -0
- package/dist/manifest/sources/local-test.d.ts +7 -0
- package/dist/manifest/sources/local-test.d.ts.map +1 -0
- package/dist/manifest/sources/local-test.js +21 -0
- package/dist/manifest/sources/local-test.js.map +1 -0
- package/dist/manifest/sources/static.d.ts +7 -0
- package/dist/manifest/sources/static.d.ts.map +1 -0
- package/dist/manifest/sources/static.js +19 -0
- package/dist/manifest/sources/static.js.map +1 -0
- package/dist/manifest/sources/test.d.ts +7 -0
- package/dist/manifest/sources/test.d.ts.map +1 -0
- package/dist/manifest/sources/test.js +21 -0
- package/dist/manifest/sources/test.js.map +1 -0
- package/dist/manifest/sources/types.d.ts +79 -0
- package/dist/manifest/sources/types.d.ts.map +1 -0
- package/dist/manifest/sources/types.js +61 -0
- package/dist/manifest/sources/types.js.map +1 -0
- package/dist/manifest/static-manifest.d.ts +4 -0
- package/dist/manifest/static-manifest.d.ts.map +1 -0
- package/dist/manifest/static-manifest.js +1535 -0
- package/dist/manifest/static-manifest.js.map +1 -0
- package/dist/manifest/store.d.ts +111 -0
- package/dist/manifest/store.d.ts.map +1 -0
- package/dist/manifest/store.js +431 -0
- package/dist/manifest/store.js.map +1 -0
- package/dist/manifest/test-manifest-loader.d.ts +3 -0
- package/dist/manifest/test-manifest-loader.d.ts.map +1 -0
- package/dist/manifest/test-manifest-stub.d.ts +4 -0
- package/dist/manifest/test-manifest-stub.d.ts.map +1 -0
- package/dist/manifest/test-manifest-stub.js +80013 -0
- package/dist/manifest/test-manifest-stub.js.map +1 -0
- package/dist/manifest.d.ts +8 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +20 -0
- package/dist/manifest.js.map +1 -0
- package/dist/manifest.json +1489 -0
- package/dist/mcp-advisor/index.d.ts +499 -0
- package/dist/mcp-advisor/index.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/add-ai-methods.d.ts +6 -0
- package/dist/mcp-advisor/tools/add-ai-methods.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/configure-decorators.d.ts +6 -0
- package/dist/mcp-advisor/tools/configure-decorators.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/generate-collection.d.ts +6 -0
- package/dist/mcp-advisor/tools/generate-collection.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/generate-field-definitions.d.ts +6 -0
- package/dist/mcp-advisor/tools/generate-field-definitions.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/generate-smrt-class.d.ts +6 -0
- package/dist/mcp-advisor/tools/generate-smrt-class.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/get-object-config.d.ts +6 -0
- package/dist/mcp-advisor/tools/get-object-config.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/get-object-schema.d.ts +10 -0
- package/dist/mcp-advisor/tools/get-object-schema.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/list-registered-objects.d.ts +9 -0
- package/dist/mcp-advisor/tools/list-registered-objects.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts +9 -0
- package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/preview-mcp-tools.d.ts +9 -0
- package/dist/mcp-advisor/tools/preview-mcp-tools.d.ts.map +1 -0
- package/dist/mcp-advisor/tools/validate-smrt-object.d.ts +6 -0
- package/dist/mcp-advisor/tools/validate-smrt-object.d.ts.map +1 -0
- package/dist/mcp-advisor/types.d.ts +209 -0
- package/dist/mcp-advisor/types.d.ts.map +1 -0
- package/dist/migrations/backfill-tracker.d.ts +84 -0
- package/dist/migrations/backfill-tracker.d.ts.map +1 -0
- package/dist/migrations/backfill-tracker.js +118 -0
- package/dist/migrations/backfill-tracker.js.map +1 -0
- package/dist/migrations/checksum.d.ts +43 -0
- package/dist/migrations/checksum.d.ts.map +1 -0
- package/dist/migrations/checksum.js +32 -0
- package/dist/migrations/checksum.js.map +1 -0
- package/dist/migrations/differ.d.ts +186 -0
- package/dist/migrations/differ.d.ts.map +1 -0
- package/dist/migrations/differ.js +601 -0
- package/dist/migrations/differ.js.map +1 -0
- package/dist/migrations/generator.d.ts +133 -0
- package/dist/migrations/generator.d.ts.map +1 -0
- package/dist/migrations/generator.js +328 -0
- package/dist/migrations/generator.js.map +1 -0
- package/dist/migrations/index.d.ts +20 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/orchestrate.d.ts +148 -0
- package/dist/migrations/orchestrate.d.ts.map +1 -0
- package/dist/migrations/orchestrate.js +118 -0
- package/dist/migrations/orchestrate.js.map +1 -0
- package/dist/migrations/tracker.d.ts +134 -0
- package/dist/migrations/tracker.d.ts.map +1 -0
- package/dist/migrations/tracker.js +624 -0
- package/dist/migrations/tracker.js.map +1 -0
- package/dist/migrations/types.d.ts +221 -0
- package/dist/migrations/types.d.ts.map +1 -0
- package/dist/migrations.d.ts +7 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +26 -0
- package/dist/migrations.js.map +1 -0
- package/dist/node_modules/.pnpm/balanced-match@4.0.4/node_modules/balanced-match/dist/esm/index.js +56 -0
- package/dist/node_modules/.pnpm/balanced-match@4.0.4/node_modules/balanced-match/dist/esm/index.js.map +1 -0
- package/dist/node_modules/.pnpm/brace-expansion@5.0.5/node_modules/brace-expansion/dist/esm/index.js +163 -0
- package/dist/node_modules/.pnpm/brace-expansion@5.0.5/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/assert-valid-pattern.js +13 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/ast.js +654 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/ast.js.map +1 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/brace-expressions.js +111 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/escape.js +10 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/escape.js.map +1 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/index.js +824 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/index.js.map +1 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/unescape.js +10 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/unescape.js.map +1 -0
- package/dist/object.d.ts +1202 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/object.js +2731 -0
- package/dist/object.js.map +1 -0
- package/dist/polymorphic-association.d.ts +69 -0
- package/dist/polymorphic-association.d.ts.map +1 -0
- package/dist/polymorphic-association.js +96 -0
- package/dist/polymorphic-association.js.map +1 -0
- package/dist/prebuild/cli.d.ts +7 -0
- package/dist/prebuild/cli.d.ts.map +1 -0
- package/dist/prebuild/cli.js +29 -0
- package/dist/prebuild/cli.js.map +1 -0
- package/dist/prebuild/index.d.ts +22 -0
- package/dist/prebuild/index.d.ts.map +1 -0
- package/dist/prebuild/index.js +239 -0
- package/dist/prebuild/index.js.map +1 -0
- package/dist/prebuild.d.ts +8 -0
- package/dist/prebuild.d.ts.map +1 -0
- package/dist/prebuild.js +6 -0
- package/dist/prebuild.js.map +1 -0
- package/dist/registry/cache-config.d.ts +13 -0
- package/dist/registry/cache-config.d.ts.map +1 -0
- package/dist/registry/cache-config.js +17 -0
- package/dist/registry/cache-config.js.map +1 -0
- package/dist/registry/class-registration.d.ts +31 -0
- package/dist/registry/class-registration.d.ts.map +1 -0
- package/dist/registry/class-registration.js +1074 -0
- package/dist/registry/class-registration.js.map +1 -0
- package/dist/registry/collection-resolution.d.ts +45 -0
- package/dist/registry/collection-resolution.d.ts.map +1 -0
- package/dist/registry/collection-resolution.js +121 -0
- package/dist/registry/collection-resolution.js.map +1 -0
- package/dist/registry/collision-policy.d.ts +179 -0
- package/dist/registry/collision-policy.d.ts.map +1 -0
- package/dist/registry/collision-policy.js +153 -0
- package/dist/registry/collision-policy.js.map +1 -0
- package/dist/registry/diagnostics.d.ts +58 -0
- package/dist/registry/diagnostics.d.ts.map +1 -0
- package/dist/registry/diagnostics.js +54 -0
- package/dist/registry/diagnostics.js.map +1 -0
- package/dist/registry/embedding-manager.d.ts +23 -0
- package/dist/registry/embedding-manager.d.ts.map +1 -0
- package/dist/registry/embedding-manager.js +62 -0
- package/dist/registry/embedding-manager.js.map +1 -0
- package/dist/registry/index.d.ts +13 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/inheritance-resolver.d.ts +13 -0
- package/dist/registry/inheritance-resolver.d.ts.map +1 -0
- package/dist/registry/inheritance-resolver.js +244 -0
- package/dist/registry/inheritance-resolver.js.map +1 -0
- package/dist/registry/manifest-field-merge.d.ts +4 -0
- package/dist/registry/manifest-field-merge.d.ts.map +1 -0
- package/dist/registry/manifest-field-merge.js +82 -0
- package/dist/registry/manifest-field-merge.js.map +1 -0
- package/dist/registry/name-resolver.d.ts +102 -0
- package/dist/registry/name-resolver.d.ts.map +1 -0
- package/dist/registry/name-resolver.js +241 -0
- package/dist/registry/name-resolver.js.map +1 -0
- package/dist/registry/relationship-graph.d.ts +16 -0
- package/dist/registry/relationship-graph.d.ts.map +1 -0
- package/dist/registry/relationship-graph.js +79 -0
- package/dist/registry/relationship-graph.js.map +1 -0
- package/dist/registry/schema-builder.d.ts +113 -0
- package/dist/registry/schema-builder.d.ts.map +1 -0
- package/dist/registry/schema-builder.js +474 -0
- package/dist/registry/schema-builder.js.map +1 -0
- package/dist/registry/shared-state.d.ts +62 -0
- package/dist/registry/shared-state.d.ts.map +1 -0
- package/dist/registry/shared-state.js +135 -0
- package/dist/registry/shared-state.js.map +1 -0
- package/dist/registry/types.d.ts +667 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/validator.d.ts +13 -0
- package/dist/registry/validator.d.ts.map +1 -0
- package/dist/registry/validator.js +138 -0
- package/dist/registry/validator.js.map +1 -0
- package/dist/registry.d.ts +1358 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +2301 -0
- package/dist/registry.js.map +1 -0
- package/dist/runtime/client.d.ts +34 -0
- package/dist/runtime/client.d.ts.map +1 -0
- package/dist/runtime/client.js +104 -0
- package/dist/runtime/client.js.map +1 -0
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/mcp.d.ts +47 -0
- package/dist/runtime/mcp.d.ts.map +1 -0
- package/dist/runtime/mcp.js +72 -0
- package/dist/runtime/mcp.js.map +1 -0
- package/dist/runtime/server.d.ts +92 -0
- package/dist/runtime/server.d.ts.map +1 -0
- package/dist/runtime/server.js +390 -0
- package/dist/runtime/server.js.map +1 -0
- package/dist/runtime/types.d.ts +58 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime.d.ts +8 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +10 -0
- package/dist/runtime.js.map +1 -0
- package/dist/scanner/import-scanner.d.ts +7 -0
- package/dist/scanner/import-scanner.d.ts.map +1 -0
- package/dist/scanner/index.d.ts +12 -0
- package/dist/scanner/index.d.ts.map +1 -0
- package/dist/scanner/manifest-generator.d.ts +304 -0
- package/dist/scanner/manifest-generator.d.ts.map +1 -0
- package/dist/scanner/manifest-generator.js +1707 -0
- package/dist/scanner/manifest-generator.js.map +1 -0
- package/dist/scanner/test-file-patterns.d.ts +18 -0
- package/dist/scanner/test-file-patterns.d.ts.map +1 -0
- package/dist/scanner/test-file-patterns.js +16 -0
- package/dist/scanner/test-file-patterns.js.map +1 -0
- package/dist/scanner/types.d.ts +313 -0
- package/dist/scanner/types.d.ts.map +1 -0
- package/dist/scanner/types.js +2 -0
- package/dist/scanner/types.js.map +1 -0
- package/dist/scanner.d.ts +6 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +6 -0
- package/dist/scanner.js.map +1 -0
- package/dist/schema/code-generator.d.ts +53 -0
- package/dist/schema/code-generator.d.ts.map +1 -0
- package/dist/schema/ddl/base-strategy.d.ts +80 -0
- package/dist/schema/ddl/base-strategy.d.ts.map +1 -0
- package/dist/schema/ddl/base-strategy.js +240 -0
- package/dist/schema/ddl/base-strategy.js.map +1 -0
- package/dist/schema/ddl/duckdb-strategy.d.ts +33 -0
- package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -0
- package/dist/schema/ddl/duckdb-strategy.js +74 -0
- package/dist/schema/ddl/duckdb-strategy.js.map +1 -0
- package/dist/schema/ddl/index.d.ts +53 -0
- package/dist/schema/ddl/index.d.ts.map +1 -0
- package/dist/schema/ddl/index.js +80 -0
- package/dist/schema/ddl/index.js.map +1 -0
- package/dist/schema/ddl/json-duckdb-strategy.d.ts +8 -0
- package/dist/schema/ddl/json-duckdb-strategy.d.ts.map +1 -0
- package/dist/schema/ddl/json-duckdb-strategy.js +14 -0
- package/dist/schema/ddl/json-duckdb-strategy.js.map +1 -0
- package/dist/schema/ddl/postgres-strategy.d.ts +29 -0
- package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -0
- package/dist/schema/ddl/postgres-strategy.js +102 -0
- package/dist/schema/ddl/postgres-strategy.js.map +1 -0
- package/dist/schema/ddl/sqlite-strategy.d.ts +38 -0
- package/dist/schema/ddl/sqlite-strategy.d.ts.map +1 -0
- package/dist/schema/ddl/sqlite-strategy.js +74 -0
- package/dist/schema/ddl/sqlite-strategy.js.map +1 -0
- package/dist/schema/ddl/types.d.ts +114 -0
- package/dist/schema/ddl/types.d.ts.map +1 -0
- package/dist/schema/generator.d.ts +176 -0
- package/dist/schema/generator.d.ts.map +1 -0
- package/dist/schema/generator.js +1076 -0
- package/dist/schema/generator.js.map +1 -0
- package/dist/schema/index-utils.d.ts +19 -0
- package/dist/schema/index-utils.d.ts.map +1 -0
- package/dist/schema/index-utils.js +32 -0
- package/dist/schema/index-utils.js.map +1 -0
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/override-system.d.ts +43 -0
- package/dist/schema/override-system.d.ts.map +1 -0
- package/dist/schema/schema-aggregator.d.ts +112 -0
- package/dist/schema/schema-aggregator.d.ts.map +1 -0
- package/dist/schema/schema-manager.d.ts +95 -0
- package/dist/schema/schema-manager.d.ts.map +1 -0
- package/dist/schema/schema-manager.js +283 -0
- package/dist/schema/schema-manager.js.map +1 -0
- package/dist/schema/sql-identifiers.d.ts +107 -0
- package/dist/schema/sql-identifiers.d.ts.map +1 -0
- package/dist/schema/sql-identifiers.js +190 -0
- package/dist/schema/sql-identifiers.js.map +1 -0
- package/dist/schema/table-verifier.d.ts +10 -0
- package/dist/schema/table-verifier.d.ts.map +1 -0
- package/dist/schema/table-verifier.js +37 -0
- package/dist/schema/table-verifier.js.map +1 -0
- package/dist/schema/types.d.ts +241 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/utils.d.ts +32 -0
- package/dist/schema/utils.d.ts.map +1 -0
- package/dist/schema/utils.js +134 -0
- package/dist/schema/utils.js.map +1 -0
- package/dist/scripts/create-wrappers.js +89 -0
- package/dist/scripts/generate-manifest.js +155 -0
- package/dist/scripts/generate-test-manifest.js +77 -0
- package/dist/scripts/migrate-datetime-to-timestamp.ts +310 -0
- package/dist/scripts/prepack.js +49 -0
- package/dist/signals/bus.d.ts +64 -0
- package/dist/signals/bus.d.ts.map +1 -0
- package/dist/signals/bus.js +102 -0
- package/dist/signals/bus.js.map +1 -0
- package/dist/signals/index.d.ts +11 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/sanitizer.d.ts +54 -0
- package/dist/signals/sanitizer.d.ts.map +1 -0
- package/dist/signals/sanitizer.js +111 -0
- package/dist/signals/sanitizer.js.map +1 -0
- package/dist/smrt-knowledge.json +335 -0
- package/dist/system/compatibility.d.ts +8 -0
- package/dist/system/compatibility.d.ts.map +1 -0
- package/dist/system/compatibility.js +409 -0
- package/dist/system/compatibility.js.map +1 -0
- package/dist/system/index.d.ts +9 -0
- package/dist/system/index.d.ts.map +1 -0
- package/dist/system/schema.d.ts +69 -0
- package/dist/system/schema.d.ts.map +1 -0
- package/dist/system/schema.js +271 -0
- package/dist/system/schema.js.map +1 -0
- package/dist/system/types.d.ts +135 -0
- package/dist/system/types.d.ts.map +1 -0
- package/dist/system-fields.d.ts +44 -0
- package/dist/system-fields.d.ts.map +1 -0
- package/dist/system-fields.js +55 -0
- package/dist/system-fields.js.map +1 -0
- package/dist/table-cache.d.ts +28 -0
- package/dist/table-cache.d.ts.map +1 -0
- package/dist/table-cache.js +21 -0
- package/dist/table-cache.js.map +1 -0
- package/dist/test-utils.d.ts +140 -0
- package/dist/test-utils.d.ts.map +1 -0
- package/dist/testing/database.d.ts +73 -0
- package/dist/testing/database.d.ts.map +1 -0
- package/dist/testing/database.js +204 -0
- package/dist/testing/database.js.map +1 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing.d.ts +6 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +5 -0
- package/dist/testing.js.map +1 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/tool-executor.d.ts +101 -0
- package/dist/tools/tool-executor.d.ts.map +1 -0
- package/dist/tools/tool-executor.js +142 -0
- package/dist/tools/tool-executor.js.map +1 -0
- package/dist/tools/tool-generator.d.ts +54 -0
- package/dist/tools/tool-generator.d.ts.map +1 -0
- package/dist/tools/tool-generator.js +121 -0
- package/dist/tools/tool-generator.js.map +1 -0
- package/dist/utils/chunk.d.ts +32 -0
- package/dist/utils/chunk.d.ts.map +1 -0
- package/dist/utils/chunk.js +14 -0
- package/dist/utils/chunk.js.map +1 -0
- package/dist/utils/import-workspace-module.d.ts +8 -0
- package/dist/utils/import-workspace-module.d.ts.map +1 -0
- package/dist/utils/import-workspace-module.js +81 -0
- package/dist/utils/import-workspace-module.js.map +1 -0
- package/dist/utils/json.d.ts +102 -0
- package/dist/utils/json.d.ts.map +1 -0
- package/dist/utils/json.js +43 -0
- package/dist/utils/json.js.map +1 -0
- package/dist/utils/lru-cache.d.ts +69 -0
- package/dist/utils/lru-cache.d.ts.map +1 -0
- package/dist/utils/lru-cache.js +100 -0
- package/dist/utils/lru-cache.js.map +1 -0
- package/dist/utils/naming.d.ts +16 -0
- package/dist/utils/naming.d.ts.map +1 -0
- package/dist/utils/naming.js +23 -0
- package/dist/utils/naming.js.map +1 -0
- package/dist/utils/qualified-names.d.ts +122 -0
- package/dist/utils/qualified-names.d.ts.map +1 -0
- package/dist/utils/qualified-names.js +82 -0
- package/dist/utils/qualified-names.js.map +1 -0
- package/dist/utils/scanner-module.d.ts +37 -0
- package/dist/utils/scanner-module.d.ts.map +1 -0
- package/dist/utils.d.ts +177 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +185 -0
- package/dist/utils.js.map +1 -0
- package/dist/vite-plugin/import-build-aware.d.ts +68 -0
- package/dist/vite-plugin/import-build-aware.d.ts.map +1 -0
- package/dist/vite-plugin/import-build-aware.js +72 -0
- package/dist/vite-plugin/import-build-aware.js.map +1 -0
- package/dist/vite-plugin/index.d.ts +59 -0
- package/dist/vite-plugin/index.d.ts.map +1 -0
- package/dist/vite-plugin/index.js +1400 -0
- package/dist/vite-plugin/index.js.map +1 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts +66 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -0
- package/dist/vite-plugin/sveltekit-generator.js +1375 -0
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -0
- package/dist/vite-plugin/templates/default-ui.ts +432 -0
- package/dist/vite-plugin/templates/default.html +206 -0
- package/dist/vite-plugin.d.ts +8 -0
- package/dist/vite-plugin.d.ts.map +1 -0
- package/dist/vite-plugin.js +11 -0
- package/dist/vite-plugin.js.map +1 -0
- package/package.json +208 -0
package/dist/object.js
ADDED
|
@@ -0,0 +1,2731 @@
|
|
|
1
|
+
import { createLogger } from "@happyvertical/logger";
|
|
2
|
+
import { SmrtClass } from "./class.js";
|
|
3
|
+
import { resolveDbCacheKey, invalidateCollectionCache, broadcastCacheInvalidation, hasCrossProcessCacheInterest } from "./collection-cache.js";
|
|
4
|
+
import { ContentHasher } from "./embeddings/hash.js";
|
|
5
|
+
import { EmbeddingProvider } from "./embeddings/provider.js";
|
|
6
|
+
import { EmbeddingStorage } from "./embeddings/storage.js";
|
|
7
|
+
import { ErrorUtils, DatabaseError, ValidationError, SmrtError, RuntimeError, TenantIsolationError } from "./errors.js";
|
|
8
|
+
import { createInterceptorContext, GlobalInterceptors } from "./interceptors.js";
|
|
9
|
+
import { ObjectRegistry } from "./registry.js";
|
|
10
|
+
import { verifyPersistenceTable } from "./schema/table-verifier.js";
|
|
11
|
+
import { executeToolCall } from "./tools/tool-executor.js";
|
|
12
|
+
import { fieldsFromClass, tableNameFromClass } from "./utils.js";
|
|
13
|
+
import { toSnakeCase } from "./utils/naming.js";
|
|
14
|
+
const logger = createLogger({
|
|
15
|
+
level: process.env.DEBUG_STI ? "debug" : "info"
|
|
16
|
+
});
|
|
17
|
+
const AI_PROMPT_DATA_MAX_LENGTH = 1e5;
|
|
18
|
+
function isValidMetaType(actualMetaType, className) {
|
|
19
|
+
if (typeof actualMetaType !== "string") {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (actualMetaType === className) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
const registeredClass = ObjectRegistry.getClass(className);
|
|
26
|
+
if (registeredClass?.qualifiedName && actualMetaType === registeredClass.qualifiedName) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
function getExpectedMetaType(className) {
|
|
32
|
+
const registeredClass = ObjectRegistry.getClass(className);
|
|
33
|
+
return registeredClass?.qualifiedName || className;
|
|
34
|
+
}
|
|
35
|
+
function getSTIHierarchyMembers(qualifiedOrSimpleName) {
|
|
36
|
+
const registered = ObjectRegistry.getClass(qualifiedOrSimpleName);
|
|
37
|
+
const lookupKey = registered?.qualifiedName ?? registered?.name ?? qualifiedOrSimpleName;
|
|
38
|
+
const stiBase = ObjectRegistry.getSTIBase(lookupKey);
|
|
39
|
+
if (!stiBase) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return Array.from(
|
|
43
|
+
/* @__PURE__ */ new Set([stiBase, ...ObjectRegistry.getDescendants(stiBase)])
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
let didWarnSkipRehydrate = false;
|
|
47
|
+
function warnIfSkipRehydrateSet() {
|
|
48
|
+
if (didWarnSkipRehydrate) return;
|
|
49
|
+
didWarnSkipRehydrate = true;
|
|
50
|
+
if (process.env.SMRT_SKIP_STI_REHYDRATE !== void 0) {
|
|
51
|
+
logger.warn(
|
|
52
|
+
"[smrt] SMRT_SKIP_STI_REHYDRATE is set but has no effect since Release C (#1134). The flag is retired; unconditional STI descendant rehydration is the only behavior. Remove the env variable from your configuration. See issue #1139 for the perf follow-up."
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
class SmrtObject extends SmrtClass {
|
|
57
|
+
/**
|
|
58
|
+
* Database table name for this object
|
|
59
|
+
*/
|
|
60
|
+
_tableName;
|
|
61
|
+
/**
|
|
62
|
+
* Cache for loaded relationships to avoid repeated database queries
|
|
63
|
+
* Maps fieldName to loaded object(s)
|
|
64
|
+
*/
|
|
65
|
+
_loadedRelationships = /* @__PURE__ */ new Map();
|
|
66
|
+
/**
|
|
67
|
+
* Whether this object is backed by an existing database row.
|
|
68
|
+
*
|
|
69
|
+
* Set when the object is hydrated from the database (collection
|
|
70
|
+
* get/list/query, `loadFromId()`, `loadFromSlug()`) and after a successful
|
|
71
|
+
* `save()`. `save()` uses it to target the primary key on upsert instead
|
|
72
|
+
* of the natural-key conflict columns, so natural-key edits (e.g. renaming
|
|
73
|
+
* a slug) update the existing row rather than colliding on `*_pkey`
|
|
74
|
+
* (issue #1472).
|
|
75
|
+
*/
|
|
76
|
+
_persisted = false;
|
|
77
|
+
/**
|
|
78
|
+
* Unique identifier for the object
|
|
79
|
+
*/
|
|
80
|
+
_id;
|
|
81
|
+
/**
|
|
82
|
+
* URL-friendly identifier
|
|
83
|
+
*/
|
|
84
|
+
_slug;
|
|
85
|
+
/**
|
|
86
|
+
* Optional context to scope the slug
|
|
87
|
+
*/
|
|
88
|
+
_context;
|
|
89
|
+
/**
|
|
90
|
+
* Creation timestamp
|
|
91
|
+
*/
|
|
92
|
+
created_at = null;
|
|
93
|
+
/**
|
|
94
|
+
* Last update timestamp
|
|
95
|
+
*/
|
|
96
|
+
updated_at = null;
|
|
97
|
+
/**
|
|
98
|
+
* Creates a new SmrtObject instance
|
|
99
|
+
*
|
|
100
|
+
* @param options - Configuration options including identifiers and metadata
|
|
101
|
+
* @throws Error if options is null
|
|
102
|
+
*/
|
|
103
|
+
constructor(options = {}) {
|
|
104
|
+
super(options);
|
|
105
|
+
this.options = options;
|
|
106
|
+
if (options === null) {
|
|
107
|
+
throw new Error("options cant be null");
|
|
108
|
+
}
|
|
109
|
+
this._id = options.id || null;
|
|
110
|
+
this._slug = options.slug || null;
|
|
111
|
+
this._context = options.context || "";
|
|
112
|
+
if (this.constructor !== SmrtObject && !ObjectRegistry.getClassByConstructor(
|
|
113
|
+
this.constructor
|
|
114
|
+
) && !options?._skipRegistration) {
|
|
115
|
+
ObjectRegistry.register(this.constructor, {});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
getRegisteredClassInfo() {
|
|
119
|
+
return ObjectRegistry.getClassByConstructor(
|
|
120
|
+
this.constructor
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
getResolvedClassName() {
|
|
124
|
+
return this.getRegisteredClassInfo()?.name || this.constructor.name;
|
|
125
|
+
}
|
|
126
|
+
getResolvedQualifiedName() {
|
|
127
|
+
const registered = this.getRegisteredClassInfo();
|
|
128
|
+
return registered?.qualifiedName || registered?.name || this.constructor.name;
|
|
129
|
+
}
|
|
130
|
+
getCurrentMetaType() {
|
|
131
|
+
const metaType = this._meta_type;
|
|
132
|
+
return typeof metaType === "string" ? metaType : void 0;
|
|
133
|
+
}
|
|
134
|
+
isLegacySTIDiscriminatorUpgrade(currentMetaType, nextMetaType, className) {
|
|
135
|
+
return typeof nextMetaType === "string" && typeof currentMetaType === "string" && currentMetaType !== nextMetaType && !currentMetaType.includes(":") && isValidMetaType(currentMetaType, className) && isValidMetaType(nextMetaType, className);
|
|
136
|
+
}
|
|
137
|
+
async planPersistenceWrite(className, tableStrategy, data, conflictColumns) {
|
|
138
|
+
const upsertPlan = {
|
|
139
|
+
type: "upsert",
|
|
140
|
+
conflictColumns
|
|
141
|
+
};
|
|
142
|
+
if (tableStrategy !== "sti" || !data.id) {
|
|
143
|
+
return upsertPlan;
|
|
144
|
+
}
|
|
145
|
+
const currentMetaType = this.getCurrentMetaType();
|
|
146
|
+
const nextMetaType = data._meta_type;
|
|
147
|
+
if (!this.isLegacySTIDiscriminatorUpgrade(
|
|
148
|
+
currentMetaType,
|
|
149
|
+
nextMetaType,
|
|
150
|
+
className
|
|
151
|
+
)) {
|
|
152
|
+
return upsertPlan;
|
|
153
|
+
}
|
|
154
|
+
const qualifiedMetaType = String(nextMetaType);
|
|
155
|
+
const conflictIdentity = {};
|
|
156
|
+
for (const column of conflictColumns.filter(
|
|
157
|
+
(conflictColumn) => conflictColumn !== "_meta_type"
|
|
158
|
+
)) {
|
|
159
|
+
if (!Object.hasOwn(data, column)) {
|
|
160
|
+
return upsertPlan;
|
|
161
|
+
}
|
|
162
|
+
conflictIdentity[column] = data[column];
|
|
163
|
+
}
|
|
164
|
+
const id = String(data.id);
|
|
165
|
+
const existingById = await ErrorUtils.withRetry(
|
|
166
|
+
async () => {
|
|
167
|
+
try {
|
|
168
|
+
return await this.db.get(this.tableName, { id });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw DatabaseError.queryFailed(
|
|
171
|
+
`get(${this.tableName}, legacy STI id)`,
|
|
172
|
+
error instanceof Error ? error : new Error(String(error))
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
3,
|
|
177
|
+
500
|
|
178
|
+
);
|
|
179
|
+
if (!existingById || existingById._meta_type !== currentMetaType) {
|
|
180
|
+
return upsertPlan;
|
|
181
|
+
}
|
|
182
|
+
const slug = String(data.slug ?? "");
|
|
183
|
+
const context = String(data.context ?? "");
|
|
184
|
+
const existingQualified = await ErrorUtils.withRetry(
|
|
185
|
+
async () => {
|
|
186
|
+
try {
|
|
187
|
+
return await this.db.get(this.tableName, {
|
|
188
|
+
...conflictIdentity,
|
|
189
|
+
_meta_type: qualifiedMetaType
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw DatabaseError.queryFailed(
|
|
193
|
+
`get(${this.tableName}, qualified STI conflict identity)`,
|
|
194
|
+
error instanceof Error ? error : new Error(String(error))
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
3,
|
|
199
|
+
500
|
|
200
|
+
);
|
|
201
|
+
if (existingQualified && existingQualified.id !== id) {
|
|
202
|
+
throw DatabaseError.stiDiscriminatorConflict({
|
|
203
|
+
className,
|
|
204
|
+
tableName: this.tableName,
|
|
205
|
+
id,
|
|
206
|
+
slug,
|
|
207
|
+
context,
|
|
208
|
+
conflictIdentity,
|
|
209
|
+
legacyMetaType: currentMetaType,
|
|
210
|
+
qualifiedMetaType,
|
|
211
|
+
duplicateId: String(existingQualified.id)
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return { type: "updateById", qualifiedMetaType };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Protected setter for ID to maintain type safety
|
|
218
|
+
* Used internally by collection.create() and other framework code
|
|
219
|
+
* @internal
|
|
220
|
+
*/
|
|
221
|
+
setId(id) {
|
|
222
|
+
this._id = id;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Whether this object is backed by an existing database row.
|
|
226
|
+
*
|
|
227
|
+
* True after hydration from the database (collection get/list/query,
|
|
228
|
+
* `loadFromId()`, `loadFromSlug()`) or after a successful `save()`.
|
|
229
|
+
*/
|
|
230
|
+
get isPersisted() {
|
|
231
|
+
return this._persisted;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Marks this object as backed by an existing database row, so `save()`
|
|
235
|
+
* targets the primary key instead of the natural-key conflict columns.
|
|
236
|
+
* Called by framework hydration paths (issue #1472).
|
|
237
|
+
* @internal
|
|
238
|
+
*/
|
|
239
|
+
markAsPersisted() {
|
|
240
|
+
this._persisted = true;
|
|
241
|
+
}
|
|
242
|
+
async verifyStorageReady() {
|
|
243
|
+
await verifyPersistenceTable(
|
|
244
|
+
this.db,
|
|
245
|
+
this.tableName,
|
|
246
|
+
this.constructor.name
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Protected setter for STI discriminator to maintain type safety
|
|
251
|
+
* Used internally for Single Table Inheritance support
|
|
252
|
+
* @internal
|
|
253
|
+
*/
|
|
254
|
+
setMetaType(metaType) {
|
|
255
|
+
this._meta_type = metaType;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Smart clone helper to avoid array/object aliasing (Issue #22)
|
|
259
|
+
*
|
|
260
|
+
* Clones values to prevent shared references between options and instance properties:
|
|
261
|
+
* - Primitives (string, number, boolean, null, undefined): No clone needed
|
|
262
|
+
* - Dates: No clone needed (immutable)
|
|
263
|
+
* - Field instances: No clone needed (framework objects)
|
|
264
|
+
* - Arrays: Shallow clone
|
|
265
|
+
* - Plain objects: Shallow clone
|
|
266
|
+
* - Other objects: Pass through (class instances, etc.)
|
|
267
|
+
*
|
|
268
|
+
* @param value - Value to clone
|
|
269
|
+
* @returns Cloned value or original if no cloning needed
|
|
270
|
+
* @private
|
|
271
|
+
*/
|
|
272
|
+
cloneValue(value) {
|
|
273
|
+
if (value === null || value === void 0) return value;
|
|
274
|
+
if (typeof value !== "object") return value;
|
|
275
|
+
if (value instanceof Date) return value;
|
|
276
|
+
if (Array.isArray(value)) return [...value];
|
|
277
|
+
const proto = Object.getPrototypeOf(value);
|
|
278
|
+
if (proto === null || proto === Object.prototype) {
|
|
279
|
+
return { ...value };
|
|
280
|
+
}
|
|
281
|
+
return value;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Initialize properties from options after field initializers have run
|
|
285
|
+
* This ensures option values take precedence over default field initializer values
|
|
286
|
+
* Uses smart cloning to prevent array/object aliasing (Issue #22)
|
|
287
|
+
*/
|
|
288
|
+
async initializePropertiesFromOptions() {
|
|
289
|
+
const options = this.options;
|
|
290
|
+
if (options.created_at !== void 0) this.created_at = options.created_at;
|
|
291
|
+
if (options.updated_at !== void 0) this.updated_at = options.updated_at;
|
|
292
|
+
if (options._meta_type !== void 0) {
|
|
293
|
+
this.setMetaType(options._meta_type);
|
|
294
|
+
}
|
|
295
|
+
const fields = await fieldsFromClass(
|
|
296
|
+
this.constructor
|
|
297
|
+
);
|
|
298
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
299
|
+
if (options[key] !== void 0) {
|
|
300
|
+
const clonedValue = this.cloneValue(options[key]);
|
|
301
|
+
let descriptor = Object.getOwnPropertyDescriptor(this, key);
|
|
302
|
+
if (!descriptor) {
|
|
303
|
+
let proto = Object.getPrototypeOf(this);
|
|
304
|
+
while (proto && !descriptor) {
|
|
305
|
+
descriptor = Object.getOwnPropertyDescriptor(proto, key);
|
|
306
|
+
proto = Object.getPrototypeOf(proto);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!descriptor || descriptor.set || descriptor.writable === true) {
|
|
310
|
+
this[key] = clonedValue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Gets the unique identifier for this object
|
|
317
|
+
*/
|
|
318
|
+
get id() {
|
|
319
|
+
return this._id;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Sets the unique identifier for this object
|
|
323
|
+
*
|
|
324
|
+
* @param value - The ID to set
|
|
325
|
+
* @throws Error if the value is invalid
|
|
326
|
+
*/
|
|
327
|
+
set id(value) {
|
|
328
|
+
if (!value || value === "undefined" || value === "null") {
|
|
329
|
+
throw new Error(`id is required, ${value} given`);
|
|
330
|
+
}
|
|
331
|
+
this._id = value;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Gets the URL-friendly slug for this object
|
|
335
|
+
*/
|
|
336
|
+
get slug() {
|
|
337
|
+
return this._slug;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Sets the URL-friendly slug for this object
|
|
341
|
+
*
|
|
342
|
+
* @param value - The slug to set
|
|
343
|
+
* @throws Error if the value is invalid
|
|
344
|
+
*/
|
|
345
|
+
set slug(value) {
|
|
346
|
+
if (!value || value === "undefined" || value === "null") {
|
|
347
|
+
throw new Error(`slug is invalid, ${value} given`);
|
|
348
|
+
}
|
|
349
|
+
this._slug = value;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Gets the context that scopes this object's slug
|
|
353
|
+
*/
|
|
354
|
+
get context() {
|
|
355
|
+
return this._context || "";
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Sets the context that scopes this object's slug
|
|
359
|
+
*
|
|
360
|
+
* @param value - The context to set
|
|
361
|
+
* @throws Error if the value is invalid
|
|
362
|
+
*/
|
|
363
|
+
set context(value) {
|
|
364
|
+
if (value !== "" && !value) {
|
|
365
|
+
throw new Error(`context is invalid, ${value} given`);
|
|
366
|
+
}
|
|
367
|
+
this._context = value;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Initializes this object and optionally loads its data from the database.
|
|
371
|
+
*
|
|
372
|
+
* Initialization order:
|
|
373
|
+
* 1. Runs TypeScript field initializers (class property defaults)
|
|
374
|
+
* 2. Applies `options` values on top of defaults (options win)
|
|
375
|
+
* 3. If `options.id` is set, calls `loadFromId()` to hydrate from DB
|
|
376
|
+
* 4. If `options.slug` is set (and no id), calls `loadFromSlug()` instead
|
|
377
|
+
*
|
|
378
|
+
* Runtime schema verification is deferred until the first actual DB
|
|
379
|
+
* operation, which keeps initialization safe for SSR and prerendering
|
|
380
|
+
* without mutating application schema.
|
|
381
|
+
*
|
|
382
|
+
* Called automatically by collection methods. Call manually only when
|
|
383
|
+
* constructing objects outside of a collection.
|
|
384
|
+
*
|
|
385
|
+
* @returns This instance, ready to use (enables chaining)
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* ```typescript
|
|
389
|
+
* const product = new Product({ db: myDb, id: 'existing-uuid' });
|
|
390
|
+
* await product.initialize(); // loads product data from DB
|
|
391
|
+
* console.log(product.name);
|
|
392
|
+
* ```
|
|
393
|
+
*/
|
|
394
|
+
async initialize() {
|
|
395
|
+
await super.initialize();
|
|
396
|
+
if (!this.options._extractingFields) {
|
|
397
|
+
await this.initializePropertiesFromOptions();
|
|
398
|
+
}
|
|
399
|
+
if (this._id && !this.options._skipLoad) {
|
|
400
|
+
await this.loadFromId();
|
|
401
|
+
} else if (this._slug && !this.options._skipLoad) {
|
|
402
|
+
await this.loadFromSlug();
|
|
403
|
+
}
|
|
404
|
+
return this;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Determines if this class needs automatic property initialization from options
|
|
408
|
+
*
|
|
409
|
+
* Decorator-based classes handle property assignment in their constructor,
|
|
410
|
+
* so they don't need the automatic property initialization logic.
|
|
411
|
+
* This optimization avoids redundant work and Field instance handling.
|
|
412
|
+
*
|
|
413
|
+
* @returns True if property initialization is needed, false otherwise
|
|
414
|
+
* @private
|
|
415
|
+
*/
|
|
416
|
+
needsPropertyInitialization() {
|
|
417
|
+
const className = this.getResolvedClassName();
|
|
418
|
+
if (ObjectRegistry.hasClass(className)) {
|
|
419
|
+
const hasDecorators = ObjectRegistry.hasFieldDecorators(className);
|
|
420
|
+
if (hasDecorators) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Loads data from a database row into this object's properties
|
|
428
|
+
*
|
|
429
|
+
* STI Support: If using Single Table Inheritance (tableStrategy: 'sti'):
|
|
430
|
+
* - Merges _meta_data JSONB fields into the main data object
|
|
431
|
+
* - Validates _meta_type discriminator matches class name
|
|
432
|
+
*
|
|
433
|
+
* @param data - Database row data (with snake_case column names)
|
|
434
|
+
* @throws Error if STI validation fails
|
|
435
|
+
*/
|
|
436
|
+
async loadDataFromDb(data) {
|
|
437
|
+
const className = this.getResolvedClassName();
|
|
438
|
+
if (process.env.DEBUG_STI) {
|
|
439
|
+
logger.debug("[loadDataFromDb] Loading", {
|
|
440
|
+
class: className,
|
|
441
|
+
dataKeys: Object.keys(data),
|
|
442
|
+
metaType: data._meta_type
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const fields = await this.getFields();
|
|
446
|
+
if (process.env.DEBUG_STI) {
|
|
447
|
+
logger.debug("[loadDataFromDb] Field definitions", {
|
|
448
|
+
class: className,
|
|
449
|
+
fieldKeys: Object.keys(fields)
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
const { formatDataJs } = await import("./utils.js");
|
|
453
|
+
const formattedData = formatDataJs(data, fields);
|
|
454
|
+
const tableStrategy = ObjectRegistry.getTableStrategy(
|
|
455
|
+
this.getResolvedQualifiedName()
|
|
456
|
+
);
|
|
457
|
+
const isSTI = tableStrategy === "sti";
|
|
458
|
+
if (process.env.DEBUG_STI) {
|
|
459
|
+
logger.debug("[loadDataFromDb] After formatDataJs", {
|
|
460
|
+
class: className,
|
|
461
|
+
isSTI,
|
|
462
|
+
formattedDataKeys: Object.keys(formattedData)
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
if (isSTI) {
|
|
466
|
+
if (!formattedData._meta_type) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`STI validation failed: Missing _meta_type discriminator in database row for ${className}. Ensure the row was saved with STI support enabled.`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (!isValidMetaType(formattedData._meta_type, className)) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`STI validation failed: Type mismatch when loading ${className}. Database row has _meta_type='${formattedData._meta_type}' but expected '${getExpectedMetaType(className)}'. This usually means you're trying to load a row with the wrong class.`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
this.setMetaType(formattedData._meta_type);
|
|
477
|
+
}
|
|
478
|
+
if (process.env.DEBUG_STI) {
|
|
479
|
+
logger.debug("[loadDataFromDb] Starting field hydration", {
|
|
480
|
+
class: className,
|
|
481
|
+
fieldCount: Object.keys(fields).length
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
let hydratedCount = 0;
|
|
485
|
+
let skippedCount = 0;
|
|
486
|
+
for (const field in fields) {
|
|
487
|
+
if (Object.hasOwn(fields, field)) {
|
|
488
|
+
let descriptor = Object.getOwnPropertyDescriptor(this, field);
|
|
489
|
+
if (!descriptor) {
|
|
490
|
+
let proto = Object.getPrototypeOf(this);
|
|
491
|
+
while (proto && !descriptor) {
|
|
492
|
+
descriptor = Object.getOwnPropertyDescriptor(proto, field);
|
|
493
|
+
proto = Object.getPrototypeOf(proto);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!descriptor || descriptor.set || descriptor.writable === true) {
|
|
497
|
+
const value = formattedData[field];
|
|
498
|
+
if (process.env.DEBUG_STI && value !== void 0) {
|
|
499
|
+
logger.debug(`[loadDataFromDb] Setting field '${field}'`, {
|
|
500
|
+
value,
|
|
501
|
+
valueType: typeof value
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
this[field] = value;
|
|
505
|
+
hydratedCount++;
|
|
506
|
+
} else {
|
|
507
|
+
skippedCount++;
|
|
508
|
+
if (process.env.DEBUG_STI) {
|
|
509
|
+
logger.debug(`[loadDataFromDb] Skipping readonly field '${field}'`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (process.env.DEBUG_STI) {
|
|
515
|
+
logger.debug("[loadDataFromDb] Hydration complete", {
|
|
516
|
+
class: className,
|
|
517
|
+
hydratedCount,
|
|
518
|
+
skippedCount,
|
|
519
|
+
totalFields: Object.keys(fields).length
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
this._persisted = true;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Gets the database table name for this object
|
|
526
|
+
*/
|
|
527
|
+
get tableName() {
|
|
528
|
+
if (!this._tableName) {
|
|
529
|
+
const className = this.getResolvedClassName();
|
|
530
|
+
const qualifiedName = this.getResolvedQualifiedName();
|
|
531
|
+
const tableStrategy = ObjectRegistry.getTableStrategy(qualifiedName);
|
|
532
|
+
if (tableStrategy === "sti") {
|
|
533
|
+
const stiBase = ObjectRegistry.getSTIBase(qualifiedName);
|
|
534
|
+
if (stiBase) {
|
|
535
|
+
const baseSchema = ObjectRegistry.getSchema(stiBase);
|
|
536
|
+
if (baseSchema?.tableName) {
|
|
537
|
+
this._tableName = baseSchema.tableName;
|
|
538
|
+
} else {
|
|
539
|
+
const ownSchema = ObjectRegistry.getSchema(className);
|
|
540
|
+
this._tableName = ownSchema?.tableName || tableNameFromClass(this.constructor);
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
const ownSchema = ObjectRegistry.getSchema(className);
|
|
544
|
+
this._tableName = ownSchema?.tableName || tableNameFromClass(this.constructor);
|
|
545
|
+
}
|
|
546
|
+
} else {
|
|
547
|
+
const ownSchema = ObjectRegistry.getSchema(className);
|
|
548
|
+
this._tableName = ownSchema?.tableName || tableNameFromClass(this.constructor);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return this._tableName;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Gets field definitions and current values for this object
|
|
555
|
+
*
|
|
556
|
+
* @returns Object containing field definitions with current values
|
|
557
|
+
*/
|
|
558
|
+
async getFields() {
|
|
559
|
+
const className = this.getResolvedClassName();
|
|
560
|
+
const cachedFields = await ObjectRegistry.getAllFields(className);
|
|
561
|
+
const fields = {};
|
|
562
|
+
for (const [key, field] of cachedFields.entries()) {
|
|
563
|
+
const meta = { ...field._meta || {} };
|
|
564
|
+
delete meta.__smrtSystemField;
|
|
565
|
+
fields[key] = {
|
|
566
|
+
name: key,
|
|
567
|
+
type: field.type || "TEXT",
|
|
568
|
+
_meta: meta
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
for (const key in fields) {
|
|
572
|
+
fields[key].value = this.getPropertyValue(key);
|
|
573
|
+
}
|
|
574
|
+
return fields;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Override hook for customizing JSON serialization output.
|
|
578
|
+
*
|
|
579
|
+
* This is the **only safe way** to customize serialization. Do **not** override
|
|
580
|
+
* `toJSON()` directly — it manages STI discriminator assignment (`_meta_type`),
|
|
581
|
+
* meta-field extraction into `_meta_data`, and manifest-driven field enumeration.
|
|
582
|
+
* Overriding it will silently break STI persistence.
|
|
583
|
+
*
|
|
584
|
+
* The `data` argument already contains all persisted fields. Return a new object
|
|
585
|
+
* (spread `data` and add/modify/remove keys) to change what is serialized.
|
|
586
|
+
* Non-persisted computed properties added here are excluded from `save()` because
|
|
587
|
+
* the DB write path uses `toJSON()` output, not this hook's additions — unless
|
|
588
|
+
* those keys also correspond to real schema columns.
|
|
589
|
+
*
|
|
590
|
+
* @param data - Fully serialized object produced by the framework's `toJSON()` logic
|
|
591
|
+
* @returns Transformed data to use as the final serialization result
|
|
592
|
+
*
|
|
593
|
+
* @example
|
|
594
|
+
* ```typescript
|
|
595
|
+
* class Article extends SmrtObject {
|
|
596
|
+
* body: string = '';
|
|
597
|
+
*
|
|
598
|
+
* protected transformJSON(data: any): any {
|
|
599
|
+
* return {
|
|
600
|
+
* ...data,
|
|
601
|
+
* wordCount: this.body.split(/\s+/).length,
|
|
602
|
+
* preview: this.body.substring(0, 100),
|
|
603
|
+
* };
|
|
604
|
+
* }
|
|
605
|
+
* }
|
|
606
|
+
* ```
|
|
607
|
+
*
|
|
608
|
+
* @see {@link toJSON} for the framework implementation (do not override)
|
|
609
|
+
*/
|
|
610
|
+
transformJSON(data) {
|
|
611
|
+
return data;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Custom JSON serialization
|
|
615
|
+
* Returns a plain object with all field values for proper JSON.stringify() behavior
|
|
616
|
+
* Field instances automatically call their toJSON() method during serialization
|
|
617
|
+
*
|
|
618
|
+
* Issue #205: Filters out undefined values to prevent database errors
|
|
619
|
+
*
|
|
620
|
+
* Note: This method cannot be async because JSON.stringify() expects synchronous toJSON()
|
|
621
|
+
* It uses direct property access instead of getFields() to avoid async issues
|
|
622
|
+
*
|
|
623
|
+
* STI Support: If using Single Table Inheritance (tableStrategy: 'sti'):
|
|
624
|
+
* - Sets _meta_type discriminator to class name
|
|
625
|
+
* - Extracts meta fields to _meta_data JSONB column
|
|
626
|
+
*
|
|
627
|
+
* **Customization:** Override `transformJSON()` instead of this method.
|
|
628
|
+
* See transformJSON() documentation for safe customization patterns.
|
|
629
|
+
*/
|
|
630
|
+
toJSON() {
|
|
631
|
+
const className = this.getResolvedClassName();
|
|
632
|
+
const data = {
|
|
633
|
+
id: this.id,
|
|
634
|
+
slug: this.slug,
|
|
635
|
+
context: this.context,
|
|
636
|
+
created_at: this.created_at,
|
|
637
|
+
updated_at: this.updated_at
|
|
638
|
+
};
|
|
639
|
+
const tableStrategy = ObjectRegistry.getTableStrategy(
|
|
640
|
+
this.getResolvedQualifiedName()
|
|
641
|
+
);
|
|
642
|
+
const isSTI = tableStrategy === "sti";
|
|
643
|
+
if (isSTI) {
|
|
644
|
+
data._meta_type = this.getResolvedQualifiedName();
|
|
645
|
+
data._meta_data = {};
|
|
646
|
+
}
|
|
647
|
+
const registered = ObjectRegistry.getClass(className);
|
|
648
|
+
let registeredFields = registered?.inheritedFields || ObjectRegistry.getFields(className);
|
|
649
|
+
if (isSTI) {
|
|
650
|
+
const descendants = getSTIHierarchyMembers(
|
|
651
|
+
this.getResolvedQualifiedName()
|
|
652
|
+
);
|
|
653
|
+
if (descendants.length > 0) {
|
|
654
|
+
const allSTIFields = new Map(registeredFields);
|
|
655
|
+
for (const descendant of descendants) {
|
|
656
|
+
const descendantFields = ObjectRegistry.getClass(descendant)?.inheritedFields || ObjectRegistry.getFields(descendant);
|
|
657
|
+
for (const [key, value] of descendantFields) {
|
|
658
|
+
if (!allSTIFields.has(key)) {
|
|
659
|
+
allSTIFields.set(key, value);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
registeredFields = allSTIFields;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
for (const key of registeredFields.keys()) {
|
|
667
|
+
if (key.startsWith("_") || key === "id" || key === "slug" || key === "context" || key === "created_at" || key === "updated_at" || key === "options" || // Skip options object (not a database column)
|
|
668
|
+
typeof this[key] === "function") {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
const fieldDef = registeredFields.get(key);
|
|
672
|
+
if (fieldDef && (fieldDef.transient || fieldDef._meta?.transient)) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (fieldDef && (fieldDef.type === "oneToMany" || fieldDef.type === "manyToMany")) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const prop = this[key];
|
|
679
|
+
const value = this.getPropertyValue(key);
|
|
680
|
+
if (value === void 0) {
|
|
681
|
+
const fieldType = prop && typeof prop === "object" && "type" in prop && prop.type || fieldDef?.type;
|
|
682
|
+
if (fieldType === "text") {
|
|
683
|
+
const hasTenancyMarker = prop && typeof prop === "object" && "__tenancy" in prop && prop.__tenancy?.isTenantIdField || fieldDef?.__tenancy?.isTenantIdField || fieldDef?._meta?.__tenancy?.isTenantIdField;
|
|
684
|
+
if (hasTenancyMarker) {
|
|
685
|
+
if (isSTI && fieldDef?.type === "meta") {
|
|
686
|
+
data._meta_data[key] = null;
|
|
687
|
+
} else {
|
|
688
|
+
data[key] = null;
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
if (isSTI && fieldDef?.type === "meta") {
|
|
692
|
+
data._meta_data[key] = "";
|
|
693
|
+
} else {
|
|
694
|
+
data[key] = "";
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
} else if (fieldType === "json") {
|
|
698
|
+
const defaultValue = fieldDef?.default ?? null;
|
|
699
|
+
if (isSTI && fieldDef?.type === "meta") {
|
|
700
|
+
data._meta_data[key] = defaultValue;
|
|
701
|
+
} else {
|
|
702
|
+
data[key] = defaultValue;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (isSTI && fieldDef && fieldDef.type === "meta") {
|
|
708
|
+
data._meta_data[key] = value;
|
|
709
|
+
} else {
|
|
710
|
+
data[key] = value;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return this.transformJSON(data);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Converts this object to a plain JavaScript object (POJO)
|
|
717
|
+
*
|
|
718
|
+
* Unlike toJSON() which returns an object that can still be a class instance,
|
|
719
|
+
* this method returns a true plain object suitable for:
|
|
720
|
+
* - SvelteKit's load function returns (requires serializable data)
|
|
721
|
+
* - Passing data through web workers
|
|
722
|
+
* - Any context requiring non-class objects
|
|
723
|
+
*
|
|
724
|
+
* @returns Plain JavaScript object with all field values
|
|
725
|
+
*
|
|
726
|
+
* @example
|
|
727
|
+
* ```typescript
|
|
728
|
+
* // In a SvelteKit +page.server.ts load function:
|
|
729
|
+
* const users = await userCollection.list();
|
|
730
|
+
* return {
|
|
731
|
+
* users: users.map(u => u.toPlainObject())
|
|
732
|
+
* };
|
|
733
|
+
* ```
|
|
734
|
+
*/
|
|
735
|
+
toPlainObject() {
|
|
736
|
+
return JSON.parse(JSON.stringify(this));
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Collects the property names of fields marked `@field({ sensitive: true })`
|
|
740
|
+
* for this instance's class (and, under STI, every member of its hierarchy,
|
|
741
|
+
* since `toJSON()` merges sibling fields into the serialized payload).
|
|
742
|
+
*
|
|
743
|
+
* Reads both the first-class `sensitive` flag and the `_meta.sensitive`
|
|
744
|
+
* mirror so it works regardless of whether the field metadata came from the
|
|
745
|
+
* AST manifest or a runtime `@field()` decorator registration.
|
|
746
|
+
*/
|
|
747
|
+
getSensitiveFieldNames() {
|
|
748
|
+
const className = this.getResolvedClassName();
|
|
749
|
+
const registered = ObjectRegistry.getClass(className);
|
|
750
|
+
const fieldMaps = [
|
|
751
|
+
registered?.inheritedFields || ObjectRegistry.getFields(className)
|
|
752
|
+
];
|
|
753
|
+
const tableStrategy = ObjectRegistry.getTableStrategy(
|
|
754
|
+
this.getResolvedQualifiedName()
|
|
755
|
+
);
|
|
756
|
+
if (tableStrategy === "sti") {
|
|
757
|
+
const descendants = getSTIHierarchyMembers(
|
|
758
|
+
this.getResolvedQualifiedName()
|
|
759
|
+
);
|
|
760
|
+
for (const descendant of descendants) {
|
|
761
|
+
fieldMaps.push(
|
|
762
|
+
ObjectRegistry.getClass(descendant)?.inheritedFields || ObjectRegistry.getFields(descendant)
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const sensitive = /* @__PURE__ */ new Set();
|
|
767
|
+
for (const fields of fieldMaps) {
|
|
768
|
+
for (const [key, def] of fields) {
|
|
769
|
+
if (def && (def.sensitive === true || def._meta?.sensitive === true)) {
|
|
770
|
+
sensitive.add(key);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return sensitive;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Public-safe serialization for network surfaces.
|
|
778
|
+
*
|
|
779
|
+
* Returns the same shape as `toJSON()` but with every `sensitive` field
|
|
780
|
+
* removed (including STI meta fields nested under `_meta_data`). This is the
|
|
781
|
+
* serializer used by the generated REST / MCP / SvelteKit routes so that
|
|
782
|
+
* secret columns (API secrets, credentials, tax IDs) are never returned over
|
|
783
|
+
* the wire, while `toJSON()` continues to carry them for database persistence.
|
|
784
|
+
*
|
|
785
|
+
* @returns A plain object safe to send to API consumers.
|
|
786
|
+
*/
|
|
787
|
+
toPublicJSON() {
|
|
788
|
+
const json = this.toJSON();
|
|
789
|
+
const sensitiveFields = this.getSensitiveFieldNames();
|
|
790
|
+
if (sensitiveFields.size === 0) {
|
|
791
|
+
return json;
|
|
792
|
+
}
|
|
793
|
+
const metaData = json._meta_data && typeof json._meta_data === "object" ? json._meta_data : null;
|
|
794
|
+
for (const name of sensitiveFields) {
|
|
795
|
+
delete json[name];
|
|
796
|
+
if (metaData) {
|
|
797
|
+
delete metaData[name];
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return json;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Gets or generates a unique ID for this object
|
|
804
|
+
*
|
|
805
|
+
* @returns Promise resolving to the object's ID
|
|
806
|
+
*/
|
|
807
|
+
async getId() {
|
|
808
|
+
if (this.slug) {
|
|
809
|
+
await this.verifyStorageReady();
|
|
810
|
+
const saved = await this.db.get(this.tableName, {
|
|
811
|
+
slug: this.slug,
|
|
812
|
+
context: this.context
|
|
813
|
+
});
|
|
814
|
+
if (saved) {
|
|
815
|
+
this.id = saved.id;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (!this.id) {
|
|
819
|
+
this.id = crypto.randomUUID();
|
|
820
|
+
}
|
|
821
|
+
return this.id;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Returns the slug for this object, generating one if not already set.
|
|
825
|
+
*
|
|
826
|
+
* Generation falls back through the following fields in order:
|
|
827
|
+
* `name` → `title` → `label` → `id`
|
|
828
|
+
*
|
|
829
|
+
* The generated slug is lowercased, with non-alphanumeric characters
|
|
830
|
+
* replaced by hyphens and leading/trailing hyphens stripped.
|
|
831
|
+
*
|
|
832
|
+
* Called automatically by `save()` when no slug is present.
|
|
833
|
+
*
|
|
834
|
+
* @returns The existing slug or a newly generated one (may be `null` if
|
|
835
|
+
* none of the fallback fields and `id` are set)
|
|
836
|
+
*
|
|
837
|
+
* @example
|
|
838
|
+
* ```typescript
|
|
839
|
+
* const product = new Product({ name: 'My Widget' });
|
|
840
|
+
* console.log(await product.getSlug()); // 'my-widget'
|
|
841
|
+
* ```
|
|
842
|
+
*/
|
|
843
|
+
async getSlug() {
|
|
844
|
+
if (!this.slug) {
|
|
845
|
+
let sourceField = null;
|
|
846
|
+
if (this.name) {
|
|
847
|
+
sourceField = String(this.name);
|
|
848
|
+
} else if (this.title) {
|
|
849
|
+
sourceField = String(this.title);
|
|
850
|
+
} else if (this.label) {
|
|
851
|
+
sourceField = String(this.label);
|
|
852
|
+
} else if (this.id) {
|
|
853
|
+
sourceField = String(this.id);
|
|
854
|
+
}
|
|
855
|
+
if (sourceField) {
|
|
856
|
+
this.slug = sourceField.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return this.slug;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Gets the ID of this object if it's already saved in the database
|
|
863
|
+
*
|
|
864
|
+
* @returns Promise resolving to the saved ID or null if not saved
|
|
865
|
+
*/
|
|
866
|
+
async getSavedId() {
|
|
867
|
+
if (!this.id && !this.slug) {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
await this.verifyStorageReady();
|
|
871
|
+
if (this.id) {
|
|
872
|
+
const byId = await this.db.get(this.tableName, { id: this.id });
|
|
873
|
+
if (byId) return byId.id;
|
|
874
|
+
}
|
|
875
|
+
if (this.slug) {
|
|
876
|
+
const bySlug = await this.db.get(this.tableName, { slug: this.slug });
|
|
877
|
+
if (bySlug) return bySlug.id;
|
|
878
|
+
}
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Checks if this object is already saved in the database
|
|
883
|
+
*
|
|
884
|
+
* @returns Promise resolving to true if saved, false otherwise
|
|
885
|
+
*/
|
|
886
|
+
async isSaved() {
|
|
887
|
+
const saved = await this.getSavedId();
|
|
888
|
+
return !!saved;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Resolve the set of snake_case column names whose database type is `UUID`
|
|
892
|
+
* for the table this object writes to.
|
|
893
|
+
*
|
|
894
|
+
* Declared foreign keys (`@foreignKey()`) and cross-package references
|
|
895
|
+
* (`@crossPackageRef()`) generate native `UUID` columns (R11). Their default
|
|
896
|
+
* declared value in TypeScript is the empty string (`''`), which is not a
|
|
897
|
+
* valid UUID literal. Postgres rejects `''` on a `uuid` column with
|
|
898
|
+
* `invalid input syntax for type uuid`, so any optional/unset declared FK
|
|
899
|
+
* (a root entity's self-reference, an optional cross-package link, etc.)
|
|
900
|
+
* would fail to insert. We coerce `''` → `NULL` for these columns before
|
|
901
|
+
* binding (see {@link coerceEmptyUuidValuesToNull}); this method tells that
|
|
902
|
+
* coercion which columns are UUID-typed.
|
|
903
|
+
*
|
|
904
|
+
* Resolution prefers the built schema (`getSchema().columns`), which already
|
|
905
|
+
* reflects all reconciliation — e.g. a `@foreignKey()` whose target class
|
|
906
|
+
* uses `idType: 'text'` is downgraded to `TEXT` and is therefore correctly
|
|
907
|
+
* excluded here. For STI children, columns live on the STI base table, so we
|
|
908
|
+
* union the base schema's columns. When no built schema is available (pure
|
|
909
|
+
* runtime-registered classes without a manifest) we fall back to field
|
|
910
|
+
* metadata, mirroring the schema-builder's field→SQL mapping.
|
|
911
|
+
*/
|
|
912
|
+
async resolveUuidColumnNames(className) {
|
|
913
|
+
const uuidColumns = /* @__PURE__ */ new Set();
|
|
914
|
+
const collectFromSchema = (schemaName) => {
|
|
915
|
+
if (!schemaName) return;
|
|
916
|
+
const schema = ObjectRegistry.getSchema(schemaName);
|
|
917
|
+
const columns = schema?.columns;
|
|
918
|
+
if (!columns) return;
|
|
919
|
+
for (const [columnName, columnDef] of Object.entries(columns)) {
|
|
920
|
+
if (columnDef?.type === "UUID") {
|
|
921
|
+
uuidColumns.add(columnName);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
collectFromSchema(className);
|
|
926
|
+
const qualifiedName = this.getResolvedQualifiedName();
|
|
927
|
+
if (ObjectRegistry.getTableStrategy(qualifiedName) === "sti") {
|
|
928
|
+
collectFromSchema(ObjectRegistry.getSTIBase(qualifiedName));
|
|
929
|
+
}
|
|
930
|
+
if (uuidColumns.size > 0) {
|
|
931
|
+
return uuidColumns;
|
|
932
|
+
}
|
|
933
|
+
try {
|
|
934
|
+
const fields = await ObjectRegistry.getAllFields(className);
|
|
935
|
+
for (const [fieldName, field] of fields.entries()) {
|
|
936
|
+
if (!field) continue;
|
|
937
|
+
const type = field.type;
|
|
938
|
+
if (type !== "foreignKey" && type !== "crossPackageRef") {
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
const metaSqlType = field._meta?.sqlType;
|
|
942
|
+
if (metaSqlType) {
|
|
943
|
+
if (metaSqlType === "UUID") {
|
|
944
|
+
uuidColumns.add(toSnakeCase(fieldName));
|
|
945
|
+
}
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const isTextIdCrossRef = type === "crossPackageRef" && (field._meta?.idType === "text" || field.idType === "text");
|
|
949
|
+
if (!isTextIdCrossRef) {
|
|
950
|
+
uuidColumns.add(toSnakeCase(fieldName));
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
} catch {
|
|
954
|
+
}
|
|
955
|
+
return uuidColumns;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Coerce empty-string values to `NULL` for UUID-typed columns in the
|
|
959
|
+
* snake_cased write payload.
|
|
960
|
+
*
|
|
961
|
+
* Framework-level fix for the whole class of bug where a declared FK field
|
|
962
|
+
* defaults to `''` (the natural TypeScript default for `string`) and is left
|
|
963
|
+
* unset on insert — e.g. creating a ROOT entity whose optional self-reference
|
|
964
|
+
* (`previousFactId`, `parentPartnerId`, …) is empty. On Postgres `uuid`
|
|
965
|
+
* columns, `''` raises `invalid input syntax for type uuid`. Coercing to
|
|
966
|
+
* `NULL` here fixes every such field uniformly without per-field type churn
|
|
967
|
+
* or consumer-facing type changes. Mutates `data` in place.
|
|
968
|
+
*/
|
|
969
|
+
async coerceEmptyUuidValuesToNull(className, data) {
|
|
970
|
+
const uuidColumns = await this.resolveUuidColumnNames(className);
|
|
971
|
+
if (uuidColumns.size === 0) return;
|
|
972
|
+
for (const columnName of uuidColumns) {
|
|
973
|
+
if (data[columnName] === "") {
|
|
974
|
+
data[columnName] = null;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Persists this object to the database using an upsert (insert or update).
|
|
980
|
+
*
|
|
981
|
+
* Steps performed on every save:
|
|
982
|
+
* 1. Runs field-level validation (`validateBeforeSave()`)
|
|
983
|
+
* 2. Executes `beforeSave` interceptors (e.g. tenant injection)
|
|
984
|
+
* 3. Assigns a UUID `id` if not already set
|
|
985
|
+
* 4. Generates a `slug` via `getSlug()` if not already set
|
|
986
|
+
* 5. Updates `updated_at` (and sets `created_at` on first save)
|
|
987
|
+
* 6. Upserts the row with automatic retry (3 attempts, 500 ms backoff)
|
|
988
|
+
* 7. Executes `afterSave` interceptors
|
|
989
|
+
* 8. Triggers embedding generation in the background if configured
|
|
990
|
+
*
|
|
991
|
+
* For STI classes, validates that `_meta_type` is present and correct
|
|
992
|
+
* before writing to the database.
|
|
993
|
+
*
|
|
994
|
+
* @returns This instance after saving (enables chaining)
|
|
995
|
+
* @throws {ValidationError} If a required field is missing or a unique constraint is violated
|
|
996
|
+
* @throws {DatabaseError} If the table does not exist (`DB_SCHEMA_MISSING`) or the query fails
|
|
997
|
+
* @throws {RuntimeError} For any other unexpected failure during save
|
|
998
|
+
*
|
|
999
|
+
* @example
|
|
1000
|
+
* ```typescript
|
|
1001
|
+
* const product = new Product({ db: myDb, name: 'Widget', price: 9.99 });
|
|
1002
|
+
* await product.initialize();
|
|
1003
|
+
* await product.save();
|
|
1004
|
+
* console.log(product.id); // UUID assigned during save
|
|
1005
|
+
*
|
|
1006
|
+
* // Update
|
|
1007
|
+
* product.price = 14.99;
|
|
1008
|
+
* await product.save();
|
|
1009
|
+
* ```
|
|
1010
|
+
*/
|
|
1011
|
+
async save() {
|
|
1012
|
+
const className = this.getResolvedClassName();
|
|
1013
|
+
try {
|
|
1014
|
+
await this.validateBeforeSave();
|
|
1015
|
+
await this.validateCrossPackageRefs();
|
|
1016
|
+
const interceptorContext = createInterceptorContext(className, "save");
|
|
1017
|
+
await GlobalInterceptors.executeBeforeSave(this, interceptorContext);
|
|
1018
|
+
if (!this.id) {
|
|
1019
|
+
this.id = crypto.randomUUID();
|
|
1020
|
+
}
|
|
1021
|
+
if (!this.slug) {
|
|
1022
|
+
this.slug = await this.getSlug();
|
|
1023
|
+
}
|
|
1024
|
+
this.updated_at = /* @__PURE__ */ new Date();
|
|
1025
|
+
if (!this.created_at) {
|
|
1026
|
+
this.created_at = /* @__PURE__ */ new Date();
|
|
1027
|
+
}
|
|
1028
|
+
await this.verifyStorageReady();
|
|
1029
|
+
const tableStrategy = ObjectRegistry.getTableStrategy(
|
|
1030
|
+
this.getResolvedQualifiedName()
|
|
1031
|
+
);
|
|
1032
|
+
if (process.env.NODE_ENV === "development") {
|
|
1033
|
+
const hasOverride = this.toJSON !== SmrtObject.prototype.toJSON;
|
|
1034
|
+
const usesSTI = tableStrategy === "sti";
|
|
1035
|
+
if (hasOverride && usesSTI) {
|
|
1036
|
+
logger.warn(
|
|
1037
|
+
`[SMRT STI Warning] ${this.constructor.name} overrides toJSON() but uses STI.
|
|
1038
|
+
Ensure super.toJSON() is called or _meta_type is set manually.
|
|
1039
|
+
This can cause "Missing _meta_type discriminator" errors.
|
|
1040
|
+
Prefer using the transformJSON() hook instead of overriding toJSON().
|
|
1041
|
+
See issue #377: https://github.com/happyvertical/smrt/issues/377`
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (tableStrategy === "sti") {
|
|
1046
|
+
warnIfSkipRehydrateSet();
|
|
1047
|
+
const qualifiedName = this.getResolvedQualifiedName();
|
|
1048
|
+
const classesNeedingFreshSTIFieldState = Array.from(
|
|
1049
|
+
/* @__PURE__ */ new Set([qualifiedName, ...getSTIHierarchyMembers(qualifiedName)])
|
|
1050
|
+
);
|
|
1051
|
+
for (const stiClassName of classesNeedingFreshSTIFieldState) {
|
|
1052
|
+
await ObjectRegistry.getAllFields(stiClassName);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const jsonData = this.toJSON();
|
|
1056
|
+
if (tableStrategy === "sti") {
|
|
1057
|
+
if (!jsonData._meta_type) {
|
|
1058
|
+
throw new Error(
|
|
1059
|
+
`STI validation failed: Missing _meta_type discriminator when saving ${className}. This should have been set automatically by toJSON(). Please report this bug.`
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
if (!isValidMetaType(jsonData._meta_type, className)) {
|
|
1063
|
+
throw new Error(
|
|
1064
|
+
`STI validation failed: _meta_type mismatch when saving ${className}. Expected '${getExpectedMetaType(className)}' but got '${jsonData._meta_type}'. This should not happen - please report this bug.`
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const data = {};
|
|
1069
|
+
for (const [key, value] of Object.entries(jsonData)) {
|
|
1070
|
+
if (key.startsWith("_")) {
|
|
1071
|
+
data[key] = value;
|
|
1072
|
+
} else {
|
|
1073
|
+
data[toSnakeCase(key)] = value;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
await this.coerceEmptyUuidValuesToNull(className, data);
|
|
1077
|
+
const conflictColumns = ObjectRegistry.getConflictColumns(className);
|
|
1078
|
+
const writePlan = await this.planPersistenceWrite(
|
|
1079
|
+
className,
|
|
1080
|
+
tableStrategy,
|
|
1081
|
+
data,
|
|
1082
|
+
conflictColumns
|
|
1083
|
+
);
|
|
1084
|
+
const upsertConflictColumns = this._persisted && data.id ? ["id"] : conflictColumns;
|
|
1085
|
+
await ErrorUtils.withRetry(
|
|
1086
|
+
async () => {
|
|
1087
|
+
try {
|
|
1088
|
+
if (writePlan.type === "updateById") {
|
|
1089
|
+
const { id: _id, ...updateData } = data;
|
|
1090
|
+
await this.db.update(this.tableName, { id: data.id }, updateData);
|
|
1091
|
+
this.setMetaType(writePlan.qualifiedMetaType);
|
|
1092
|
+
} else {
|
|
1093
|
+
await this.db.upsert(this.tableName, upsertConflictColumns, data);
|
|
1094
|
+
}
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
if (error instanceof Error) {
|
|
1097
|
+
const kind = SmrtObject.classifyConstraintError(error.message);
|
|
1098
|
+
if (kind === "unique") {
|
|
1099
|
+
const field = this.extractConstraintField(error.message);
|
|
1100
|
+
throw ValidationError.uniqueConstraint(
|
|
1101
|
+
field,
|
|
1102
|
+
this.getFieldValue(field)
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
if (kind === "not_null") {
|
|
1106
|
+
const field = this.extractConstraintField(error.message);
|
|
1107
|
+
throw ValidationError.requiredField(field, className);
|
|
1108
|
+
}
|
|
1109
|
+
const operation = writePlan.type === "updateById" ? `UPDATE ${this.tableName} (id-targeted)` : `UPSERT INTO ${this.tableName}`;
|
|
1110
|
+
throw DatabaseError.queryFailed(operation, error);
|
|
1111
|
+
}
|
|
1112
|
+
throw error;
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
3,
|
|
1116
|
+
500
|
|
1117
|
+
);
|
|
1118
|
+
this._persisted = true;
|
|
1119
|
+
this.invalidateCollectionReadCache();
|
|
1120
|
+
await GlobalInterceptors.executeAfterSave(this, interceptorContext);
|
|
1121
|
+
const embeddingConfig = ObjectRegistry.resolveEmbeddingConfig(className);
|
|
1122
|
+
const skipAutoEmbeddings = this.options._skipAutoEmbeddings === true;
|
|
1123
|
+
if (embeddingConfig && embeddingConfig.autoGenerate !== false && !skipAutoEmbeddings) {
|
|
1124
|
+
const aiClient = await this.getOptionalAiClient();
|
|
1125
|
+
if (aiClient) {
|
|
1126
|
+
const isStale = await this.hasStaleEmbeddings();
|
|
1127
|
+
if (isStale) {
|
|
1128
|
+
this.generateEmbeddings().catch((error) => {
|
|
1129
|
+
logger.warn(
|
|
1130
|
+
`Failed to auto-generate embeddings for ${this.constructor.name}`,
|
|
1131
|
+
{ error: error instanceof Error ? error.message : error }
|
|
1132
|
+
);
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (skipAutoEmbeddings) {
|
|
1138
|
+
this.options._skipAutoEmbeddings = false;
|
|
1139
|
+
}
|
|
1140
|
+
return this;
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (error instanceof SmrtError) {
|
|
1143
|
+
throw error;
|
|
1144
|
+
}
|
|
1145
|
+
if (this.isTenantContractError(error)) {
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
throw RuntimeError.operationFailed(
|
|
1149
|
+
"save",
|
|
1150
|
+
`${className}#${this.id}`,
|
|
1151
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Detects tenant-boundary errors that must propagate from `save()` unwrapped.
|
|
1157
|
+
*
|
|
1158
|
+
* Matches both core's {@link TenantIsolationError} and the duck-typed
|
|
1159
|
+
* tenancy-package errors (`TenantIsolationError` / `TenantContextError`) that
|
|
1160
|
+
* the `beforeSave` interceptor throws. Those tenancy classes extend plain
|
|
1161
|
+
* `Error` — core cannot import the tenancy package (the dependency runs the
|
|
1162
|
+
* other way) — so they are matched by their stable `code` constants, with a
|
|
1163
|
+
* class-name fallback for resilience.
|
|
1164
|
+
*/
|
|
1165
|
+
isTenantContractError(error) {
|
|
1166
|
+
if (error instanceof TenantIsolationError) {
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
const candidate = error;
|
|
1170
|
+
const code = candidate?.code;
|
|
1171
|
+
if (code === "TENANT_ISOLATION_VIOLATION" || code === "TENANT_CONTEXT_REQUIRED") {
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
const name = candidate?.name;
|
|
1175
|
+
return name === "TenantIsolationError" || name === "TenantContextError";
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Validates object state before saving
|
|
1179
|
+
* Override in subclasses to add custom validation logic
|
|
1180
|
+
*/
|
|
1181
|
+
async validateBeforeSave() {
|
|
1182
|
+
const className = this.getResolvedClassName();
|
|
1183
|
+
const validationRules = ObjectRegistry.getValidationRules(className);
|
|
1184
|
+
if (validationRules && validationRules.length > 0) {
|
|
1185
|
+
const errors = await ObjectRegistry.validateWithRules(
|
|
1186
|
+
this,
|
|
1187
|
+
validationRules,
|
|
1188
|
+
className
|
|
1189
|
+
);
|
|
1190
|
+
if (errors.length > 0) {
|
|
1191
|
+
throw errors[0];
|
|
1192
|
+
}
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const validators = ObjectRegistry.getValidators(className);
|
|
1196
|
+
if (validators && validators.length > 0) {
|
|
1197
|
+
const errors = [];
|
|
1198
|
+
for (const validator of validators) {
|
|
1199
|
+
const error = await validator(this);
|
|
1200
|
+
if (error) {
|
|
1201
|
+
errors.push(error);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (errors.length > 0) {
|
|
1205
|
+
throw errors[0];
|
|
1206
|
+
}
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const fields = await fieldsFromClass(this.constructor);
|
|
1210
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
1211
|
+
if (field.options?.required) {
|
|
1212
|
+
const value = this.getFieldValue(fieldName);
|
|
1213
|
+
if (value === null || value === void 0 || value === "") {
|
|
1214
|
+
throw ValidationError.requiredField(fieldName, className);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Validates cross-package references that opted in via `validate: true`.
|
|
1221
|
+
*
|
|
1222
|
+
* Iterates registered fields of type `crossPackageRef`. For each one whose
|
|
1223
|
+
* field options include `validate: true` AND has a non-empty value, looks up
|
|
1224
|
+
* the referenced object via the target package's manifest and throws
|
|
1225
|
+
* `ValidationError` if the target row does not exist.
|
|
1226
|
+
*
|
|
1227
|
+
* Empty/null/undefined values are treated as "no reference set" and skipped.
|
|
1228
|
+
* Fields without `validate: true` are always skipped (the registered metadata
|
|
1229
|
+
* still powers eager loading and `loadRelated()`).
|
|
1230
|
+
*/
|
|
1231
|
+
async validateCrossPackageRefs() {
|
|
1232
|
+
const className = this.getResolvedClassName();
|
|
1233
|
+
const registered = ObjectRegistry.getClass(className);
|
|
1234
|
+
if (!registered) return;
|
|
1235
|
+
const fields = registered.inheritedFields || registered.fields;
|
|
1236
|
+
if (!fields || fields.size === 0) return;
|
|
1237
|
+
const checks = [];
|
|
1238
|
+
for (const [fieldName, field] of fields) {
|
|
1239
|
+
if (field?.type !== "crossPackageRef") continue;
|
|
1240
|
+
const opts = field._meta || field;
|
|
1241
|
+
if (!opts.validate) continue;
|
|
1242
|
+
if (!field.related) continue;
|
|
1243
|
+
const value = this.getFieldValue(fieldName);
|
|
1244
|
+
if (value === null || value === void 0 || value === "") continue;
|
|
1245
|
+
checks.push({
|
|
1246
|
+
fieldName,
|
|
1247
|
+
qualifiedTarget: String(field.related),
|
|
1248
|
+
value: String(value)
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
if (checks.length === 0) return;
|
|
1252
|
+
for (const check of checks) {
|
|
1253
|
+
await ObjectRegistry.ensureManifestLoaded(check.qualifiedTarget);
|
|
1254
|
+
const targetClass = ObjectRegistry.getClassByQualifiedName(check.qualifiedTarget) ?? ObjectRegistry.getClass(check.qualifiedTarget);
|
|
1255
|
+
if (!targetClass) {
|
|
1256
|
+
throw new ValidationError(
|
|
1257
|
+
`crossPackageRef target ${check.qualifiedTarget} for ${className}.${check.fieldName} is not registered. Ensure the target package's manifest is discoverable at runtime.`,
|
|
1258
|
+
"VALIDATION_CROSS_PACKAGE_REF_UNREGISTERED",
|
|
1259
|
+
{ className, fieldName: check.fieldName, value: check.value }
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
const probe = new targetClass.constructor(this.options);
|
|
1263
|
+
await probe.initialize();
|
|
1264
|
+
await probe.verifyStorageReady();
|
|
1265
|
+
const row = await probe.db.get(probe.tableName, { id: check.value });
|
|
1266
|
+
if (!row) {
|
|
1267
|
+
throw new ValidationError(
|
|
1268
|
+
`crossPackageRef validation failed: ${className}.${check.fieldName} references ${check.qualifiedTarget} id="${check.value}" but no such row exists.`,
|
|
1269
|
+
"VALIDATION_CROSS_PACKAGE_REF_MISSING",
|
|
1270
|
+
{ className, fieldName: check.fieldName, value: check.value }
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Gets the value of a field on this object
|
|
1277
|
+
*/
|
|
1278
|
+
getFieldValue(fieldName) {
|
|
1279
|
+
return this[fieldName];
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Gets the value of a property.
|
|
1283
|
+
*
|
|
1284
|
+
* @param key - Property name to get value from
|
|
1285
|
+
* @returns The property value
|
|
1286
|
+
*/
|
|
1287
|
+
getPropertyValue(key) {
|
|
1288
|
+
return this[key];
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Classifies a raw database driver error message as a unique-constraint or
|
|
1292
|
+
* not-null-constraint violation, across SQLite, PostgreSQL and DuckDB.
|
|
1293
|
+
*
|
|
1294
|
+
* `@happyvertical/sql` does not normalize driver errors, so each dialect's
|
|
1295
|
+
* native wording is matched case-insensitively. Returns `null` when the
|
|
1296
|
+
* message is not a recognized unique/not-null violation (the caller then
|
|
1297
|
+
* falls back to a generic `DatabaseError`). Kept as a pure static so the
|
|
1298
|
+
* cross-dialect matching can be unit-tested directly without a live PG/DuckDB
|
|
1299
|
+
* connection (#1378).
|
|
1300
|
+
*
|
|
1301
|
+
* @param message - The raw driver error message.
|
|
1302
|
+
* @returns `'unique'`, `'not_null'`, or `null`.
|
|
1303
|
+
*/
|
|
1304
|
+
static classifyConstraintError(message) {
|
|
1305
|
+
if (!message) {
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
if (/NOT NULL constraint failed/i.test(message) || /null value in column .* violates not-null/i.test(message)) {
|
|
1309
|
+
return "not_null";
|
|
1310
|
+
}
|
|
1311
|
+
if (/UNIQUE constraint failed/i.test(message) || /violates unique constraint/i.test(message) || /violates primary key constraint/i.test(message)) {
|
|
1312
|
+
return "unique";
|
|
1313
|
+
}
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Extracts field name from database constraint error messages.
|
|
1318
|
+
*
|
|
1319
|
+
* Handles SQLite, PostgreSQL and DuckDB phrasings. Returns
|
|
1320
|
+
* `'unknown_field'` when no column name can be recovered.
|
|
1321
|
+
*/
|
|
1322
|
+
extractConstraintField(errorMessage) {
|
|
1323
|
+
const patterns = [
|
|
1324
|
+
// SQLite / DuckDB: "UNIQUE constraint failed: products.slug"
|
|
1325
|
+
/UNIQUE constraint failed: \w+\.(\w+)/i,
|
|
1326
|
+
// SQLite / DuckDB: "NOT NULL constraint failed: products.name"
|
|
1327
|
+
/NOT NULL constraint failed: \w+\.(\w+)/i,
|
|
1328
|
+
// PostgreSQL not-null: 'null value in column "name" ...'
|
|
1329
|
+
/null value in column "([^"]+)"/i,
|
|
1330
|
+
// PostgreSQL unique DETAIL line: 'Key (slug)=(foo) already exists.'
|
|
1331
|
+
/Key \(([^)]+)\)=/i,
|
|
1332
|
+
// SQLite / DuckDB generic fallback: "constraint failed: slug"
|
|
1333
|
+
/constraint failed: (\w+)/i
|
|
1334
|
+
];
|
|
1335
|
+
for (const pattern of patterns) {
|
|
1336
|
+
const match = errorMessage.match(pattern);
|
|
1337
|
+
if (match?.[1]) {
|
|
1338
|
+
return match[1];
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return "unknown_field";
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Hydrates this object from the database using its `id` property.
|
|
1345
|
+
*
|
|
1346
|
+
* Queries the database for a row matching `{ id: this._id }` and calls
|
|
1347
|
+
* `loadDataFromDb()` if found. Uses a 3-attempt retry with 250 ms initial
|
|
1348
|
+
* delay to handle transient failures.
|
|
1349
|
+
*
|
|
1350
|
+
* Called automatically by `initialize()` when `options.id` is provided.
|
|
1351
|
+
* Typically you do not need to call this directly.
|
|
1352
|
+
*
|
|
1353
|
+
* @returns Promise that resolves when loading is complete (no-op if not found)
|
|
1354
|
+
* @throws {ValidationError} If `this._id` is not set
|
|
1355
|
+
* @throws {DatabaseError} If the query fails after all retries
|
|
1356
|
+
*
|
|
1357
|
+
* @example
|
|
1358
|
+
* ```typescript
|
|
1359
|
+
* const product = new Product({ db: myDb });
|
|
1360
|
+
* await product.initialize();
|
|
1361
|
+
* product._id = 'some-uuid';
|
|
1362
|
+
* await product.loadFromId(); // hydrates from DB
|
|
1363
|
+
* ```
|
|
1364
|
+
*/
|
|
1365
|
+
async loadFromId() {
|
|
1366
|
+
try {
|
|
1367
|
+
if (!this._id) {
|
|
1368
|
+
throw ValidationError.requiredField("id", this.constructor.name);
|
|
1369
|
+
}
|
|
1370
|
+
await this.verifyStorageReady();
|
|
1371
|
+
await ErrorUtils.withRetry(
|
|
1372
|
+
async () => {
|
|
1373
|
+
try {
|
|
1374
|
+
const existing = await this.db.get(this.tableName, {
|
|
1375
|
+
id: this._id
|
|
1376
|
+
});
|
|
1377
|
+
if (existing) {
|
|
1378
|
+
await this.loadDataFromDb(existing);
|
|
1379
|
+
}
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
throw DatabaseError.queryFailed(
|
|
1382
|
+
`get(${this.tableName}, { id: ${this._id} })`,
|
|
1383
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
},
|
|
1387
|
+
3,
|
|
1388
|
+
250
|
|
1389
|
+
);
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
if (error instanceof ValidationError || error instanceof DatabaseError) {
|
|
1392
|
+
throw error;
|
|
1393
|
+
}
|
|
1394
|
+
throw RuntimeError.operationFailed(
|
|
1395
|
+
"loadFromId",
|
|
1396
|
+
`${this.constructor.name}#${this._id}`,
|
|
1397
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Hydrates this object from the database using its `slug` (and `context`).
|
|
1403
|
+
*
|
|
1404
|
+
* Queries for a row matching `{ slug, context }`. The `context` defaults to
|
|
1405
|
+
* an empty string when not provided. Calls `loadDataFromDb()` if a matching
|
|
1406
|
+
* row is found.
|
|
1407
|
+
*
|
|
1408
|
+
* Called automatically by `initialize()` when `options.slug` is provided and
|
|
1409
|
+
* no `options.id` is set. Typically you do not need to call this directly.
|
|
1410
|
+
*
|
|
1411
|
+
* @returns Promise that resolves when loading is complete (no-op if not found)
|
|
1412
|
+
*
|
|
1413
|
+
* @example
|
|
1414
|
+
* ```typescript
|
|
1415
|
+
* const product = new Product({ db: myDb, slug: 'my-widget', context: 'shop' });
|
|
1416
|
+
* await product.initialize(); // calls loadFromSlug() automatically
|
|
1417
|
+
* console.log(product.name);
|
|
1418
|
+
* ```
|
|
1419
|
+
*/
|
|
1420
|
+
async loadFromSlug() {
|
|
1421
|
+
await this.verifyStorageReady();
|
|
1422
|
+
const existing = await this.db.get(this.tableName, {
|
|
1423
|
+
slug: this._slug,
|
|
1424
|
+
context: this._context || ""
|
|
1425
|
+
});
|
|
1426
|
+
if (existing) {
|
|
1427
|
+
await this.loadDataFromDb(existing);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Serializes this instance into the "content body" injected into the AI
|
|
1432
|
+
* prompts used by {@link is}, {@link do}, and {@link describe} so those
|
|
1433
|
+
* methods reason over the object's own field data, not just the caller's
|
|
1434
|
+
* instruction string.
|
|
1435
|
+
*
|
|
1436
|
+
* Uses {@link toPublicJSON} (never {@link toJSON}) so that
|
|
1437
|
+
* `@field({ sensitive: true })` values — API secrets, credentials, tax IDs —
|
|
1438
|
+
* are never sent to the model. The serialized payload is truncated to
|
|
1439
|
+
* `maxLength` characters as a coarse token-budget guard for large instances;
|
|
1440
|
+
* truncation appends a clear marker so the model knows the data was cut.
|
|
1441
|
+
*
|
|
1442
|
+
* @param maxLength - Maximum characters of object data to include
|
|
1443
|
+
* (defaults to {@link AI_PROMPT_DATA_MAX_LENGTH}). A non-positive value
|
|
1444
|
+
* disables truncation.
|
|
1445
|
+
* @returns A JSON string of the object's public (sensitive-stripped) fields
|
|
1446
|
+
*/
|
|
1447
|
+
serializeForAiPrompt(maxLength = AI_PROMPT_DATA_MAX_LENGTH) {
|
|
1448
|
+
let serialized;
|
|
1449
|
+
try {
|
|
1450
|
+
serialized = JSON.stringify(this.toPublicJSON(), null, 2);
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
logger.warn(
|
|
1453
|
+
`Failed to serialize ${this.constructor.name} for AI prompt: ${error instanceof Error ? error.message : String(error)}`
|
|
1454
|
+
);
|
|
1455
|
+
serialized = "{}";
|
|
1456
|
+
}
|
|
1457
|
+
if (Number.isFinite(maxLength) && maxLength > 0 && serialized.length > maxLength) {
|
|
1458
|
+
const marker = `
|
|
1459
|
+
… [truncated: object data exceeded ${maxLength} characters]`;
|
|
1460
|
+
const keep = Math.max(0, maxLength - marker.length);
|
|
1461
|
+
serialized = `${serialized.slice(0, keep)}${marker}`;
|
|
1462
|
+
}
|
|
1463
|
+
return serialized;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Builds the optional "content body" section prepended to the AI prompts in
|
|
1467
|
+
* {@link is}, {@link do}, and {@link describe}.
|
|
1468
|
+
*
|
|
1469
|
+
* Returns an empty string when the caller opts out with `includeData: false`
|
|
1470
|
+
* — used by callers that already curate the object's relevant fields into
|
|
1471
|
+
* their own instruction/criteria string (so the data is not duplicated, and
|
|
1472
|
+
* the prompt stays exactly as the caller authored it). Otherwise it wraps
|
|
1473
|
+
* {@link serializeForAiPrompt} in `--- Beginning/End of content ---`
|
|
1474
|
+
* delimiters so the methods reason over the instance's own data by default.
|
|
1475
|
+
*
|
|
1476
|
+
* @param includeData - `false` to omit the section; any other value (incl.
|
|
1477
|
+
* `undefined`) injects it
|
|
1478
|
+
* @param maxLength - Forwarded to {@link serializeForAiPrompt} as the
|
|
1479
|
+
* truncation budget
|
|
1480
|
+
* @returns The content-body section (with a trailing newline) or `''`
|
|
1481
|
+
*/
|
|
1482
|
+
buildAiContentSection(includeData, maxLength) {
|
|
1483
|
+
if (includeData === false) {
|
|
1484
|
+
return "";
|
|
1485
|
+
}
|
|
1486
|
+
const contentBody = this.serializeForAiPrompt(maxLength);
|
|
1487
|
+
return `--- Beginning of content ---
|
|
1488
|
+
${contentBody}
|
|
1489
|
+
--- End of content ---
|
|
1490
|
+
`;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Evaluates whether this object satisfies the given natural-language criteria using AI.
|
|
1494
|
+
*
|
|
1495
|
+
* Injects the object's own public field data (via {@link toPublicJSON}, so
|
|
1496
|
+
* `@field({ sensitive: true })` values are excluded) into the prompt as the
|
|
1497
|
+
* "content body", then asks the AI whether that data meets the criteria and
|
|
1498
|
+
* to reply with a `{ result: boolean }` JSON response. Uses any AI-callable
|
|
1499
|
+
* tools registered on this class (via `@smrt({ ai })`) as part of the
|
|
1500
|
+
* function-calling context.
|
|
1501
|
+
*
|
|
1502
|
+
* @param criteria - Natural-language description of the condition to evaluate
|
|
1503
|
+
* @param options - AI message options passed to `ai.message()` (e.g. model
|
|
1504
|
+
* override). Two non-standard control keys are consumed here and not
|
|
1505
|
+
* forwarded to `ai.message()`:
|
|
1506
|
+
* - `includeData: false` — omit the injected object "content body" (for
|
|
1507
|
+
* callers that already curate the relevant fields into `criteria`).
|
|
1508
|
+
* - `maxDataLength` (number of characters) — override the injected
|
|
1509
|
+
* object-data truncation limit.
|
|
1510
|
+
* @returns `true` if the object meets the criteria, `false` otherwise
|
|
1511
|
+
* @throws Error if the AI returns a non-boolean or malformed JSON response
|
|
1512
|
+
*
|
|
1513
|
+
* @example
|
|
1514
|
+
* ```typescript
|
|
1515
|
+
* const article = await articles.get('article-uuid');
|
|
1516
|
+
* const isSuitable = await article.is('appropriate for a general audience');
|
|
1517
|
+
* if (isSuitable) await article.publish();
|
|
1518
|
+
* ```
|
|
1519
|
+
*
|
|
1520
|
+
* @see {@link do} for open-ended instructions instead of boolean checks
|
|
1521
|
+
*/
|
|
1522
|
+
async is(criteria, options = {}) {
|
|
1523
|
+
const ai = await this.getAiClient();
|
|
1524
|
+
const { maxDataLength, includeData, ...aiOptions } = options ?? {};
|
|
1525
|
+
const contentSection = this.buildAiContentSection(
|
|
1526
|
+
includeData,
|
|
1527
|
+
maxDataLength
|
|
1528
|
+
);
|
|
1529
|
+
const prompt = `${contentSection}--- Beginning of criteria ---
|
|
1530
|
+
${criteria}
|
|
1531
|
+
--- End of criteria ---
|
|
1532
|
+
Does the content meet all the given criteria? Reply with a json object with a single boolean 'result' property`;
|
|
1533
|
+
const tools = this.getAvailableTools();
|
|
1534
|
+
const message = await ai.message(prompt, {
|
|
1535
|
+
...aiOptions,
|
|
1536
|
+
responseFormat: { type: "json_object" },
|
|
1537
|
+
tools: tools.length > 0 ? tools : void 0
|
|
1538
|
+
});
|
|
1539
|
+
try {
|
|
1540
|
+
const { result } = JSON.parse(message);
|
|
1541
|
+
if (result === true || result === false) {
|
|
1542
|
+
return result;
|
|
1543
|
+
}
|
|
1544
|
+
} catch (_e) {
|
|
1545
|
+
throw new Error(`Unexpected answer: ${message}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Performs a freeform operation on this object using AI instructions.
|
|
1550
|
+
*
|
|
1551
|
+
* Injects the object's own public field data (via {@link toPublicJSON}, so
|
|
1552
|
+
* `@field({ sensitive: true })` values are excluded) into the prompt as the
|
|
1553
|
+
* "content body" the instructions operate on, then returns the raw text
|
|
1554
|
+
* response. Unlike `is()`, this method does not constrain the response
|
|
1555
|
+
* format — use it for transformations, summaries, extractions, or any
|
|
1556
|
+
* open-ended AI task.
|
|
1557
|
+
*
|
|
1558
|
+
* Uses any AI-callable tools registered on this class (via `@smrt({ ai })`)
|
|
1559
|
+
* as part of the function-calling context.
|
|
1560
|
+
*
|
|
1561
|
+
* @param instructions - Natural-language instructions for the AI to follow
|
|
1562
|
+
* @param options - AI message options passed to `ai.message()` (e.g. model
|
|
1563
|
+
* override). Two non-standard control keys are consumed here and not
|
|
1564
|
+
* forwarded to `ai.message()`:
|
|
1565
|
+
* - `includeData: false` — omit the injected object "content body" (for
|
|
1566
|
+
* callers that already curate the relevant fields into `instructions`).
|
|
1567
|
+
* - `maxDataLength` (number of characters) — override the injected
|
|
1568
|
+
* object-data truncation limit.
|
|
1569
|
+
* @returns The raw AI response string
|
|
1570
|
+
*
|
|
1571
|
+
* @example
|
|
1572
|
+
* ```typescript
|
|
1573
|
+
* const article = await articles.get('article-uuid');
|
|
1574
|
+
* const summary = await article.do('summarize this article in 3 bullet points');
|
|
1575
|
+
* const translated = await article.do('translate the title and body to Spanish');
|
|
1576
|
+
* ```
|
|
1577
|
+
*
|
|
1578
|
+
* @see {@link is} for boolean criteria checks
|
|
1579
|
+
*/
|
|
1580
|
+
async do(instructions, options = {}) {
|
|
1581
|
+
const ai = await this.getAiClient();
|
|
1582
|
+
const { maxDataLength, includeData, ...aiOptions } = options ?? {};
|
|
1583
|
+
const contentSection = this.buildAiContentSection(
|
|
1584
|
+
includeData,
|
|
1585
|
+
maxDataLength
|
|
1586
|
+
);
|
|
1587
|
+
const prompt = `${contentSection}--- Beginning of instructions ---
|
|
1588
|
+
${instructions}
|
|
1589
|
+
--- End of instructions ---
|
|
1590
|
+
Based on the content body, please follow the instructions and provide a response. Never make use of codeblocks.`;
|
|
1591
|
+
const tools = this.getAvailableTools();
|
|
1592
|
+
const result = await ai.message(prompt, {
|
|
1593
|
+
...aiOptions,
|
|
1594
|
+
tools: tools.length > 0 ? tools : void 0
|
|
1595
|
+
});
|
|
1596
|
+
return result;
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Generates a description of this object using AI (Issue #52)
|
|
1600
|
+
*
|
|
1601
|
+
* Creates a concise, human-readable description based on the object's content
|
|
1602
|
+
* and properties. The object's own public field data (via
|
|
1603
|
+
* {@link toPublicJSON}, so `@field({ sensitive: true })` values are excluded)
|
|
1604
|
+
* is injected into the prompt as the "content body" the description is built
|
|
1605
|
+
* from. Useful for summaries, previews, and documentation.
|
|
1606
|
+
*
|
|
1607
|
+
* @param options - AI message options (can include style, length, focus,
|
|
1608
|
+
* etc.). Two non-standard control keys are consumed here and not forwarded
|
|
1609
|
+
* to `ai.message()`:
|
|
1610
|
+
* - `includeData: false` — omit the injected object "content body".
|
|
1611
|
+
* - `maxDataLength` (number of characters) — override the injected
|
|
1612
|
+
* object-data truncation limit.
|
|
1613
|
+
* @returns Promise resolving to the AI-generated description
|
|
1614
|
+
*
|
|
1615
|
+
* @example
|
|
1616
|
+
* ```typescript
|
|
1617
|
+
* const product = await products.get('product-123');
|
|
1618
|
+
* const description = await product.describe();
|
|
1619
|
+
* // "A high-quality widget for home improvement..."
|
|
1620
|
+
*
|
|
1621
|
+
* // With custom options
|
|
1622
|
+
* const shortDesc = await product.describe({ maxTokens: 50 });
|
|
1623
|
+
* // "Premium widget, steel construction"
|
|
1624
|
+
* ```
|
|
1625
|
+
*/
|
|
1626
|
+
async describe(options = {}) {
|
|
1627
|
+
const ai = await this.getAiClient();
|
|
1628
|
+
const { maxDataLength, includeData, ...aiOptions } = options ?? {};
|
|
1629
|
+
const contentSection = this.buildAiContentSection(
|
|
1630
|
+
includeData,
|
|
1631
|
+
maxDataLength
|
|
1632
|
+
);
|
|
1633
|
+
const prompt = `${contentSection}Generate a concise, professional description of this object based on its content and properties. The description should be clear, informative, and suitable for display to end users. Focus on the most important and distinctive characteristics.`;
|
|
1634
|
+
const tools = this.getAvailableTools();
|
|
1635
|
+
const result = await ai.message(prompt, {
|
|
1636
|
+
...aiOptions,
|
|
1637
|
+
tools: tools.length > 0 ? tools : void 0
|
|
1638
|
+
});
|
|
1639
|
+
return result;
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Runs a lifecycle hook if it's defined in the object's configuration
|
|
1643
|
+
*
|
|
1644
|
+
* @param hookName - Name of the hook to run (e.g., 'beforeDelete', 'afterDelete')
|
|
1645
|
+
* @returns Promise that resolves when the hook completes
|
|
1646
|
+
*/
|
|
1647
|
+
async runHook(hookName) {
|
|
1648
|
+
const config = ObjectRegistry.getConfig(this.constructor.name);
|
|
1649
|
+
const hook = config.hooks?.[hookName];
|
|
1650
|
+
if (!hook) {
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (typeof hook === "string") {
|
|
1654
|
+
const method = this[hook];
|
|
1655
|
+
if (typeof method === "function") {
|
|
1656
|
+
await method.call(this);
|
|
1657
|
+
} else {
|
|
1658
|
+
logger.warn(
|
|
1659
|
+
`Hook method '${hook}' not found on ${this.constructor.name}`
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
} else if (typeof hook === "function") {
|
|
1663
|
+
await hook(this);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Deletes this object from the database.
|
|
1668
|
+
*
|
|
1669
|
+
* Runs the full lifecycle in order:
|
|
1670
|
+
* 1. `beforeDelete` interceptors (e.g. tenant validation)
|
|
1671
|
+
* 2. `beforeDelete` lifecycle hook (defined in `@smrt({ hooks })`)
|
|
1672
|
+
* 3. Database row deletion
|
|
1673
|
+
* 4. `afterDelete` lifecycle hook
|
|
1674
|
+
* 5. `afterDelete` interceptors
|
|
1675
|
+
*
|
|
1676
|
+
* Prefer `collection.delete(id)` from application code — it loads the
|
|
1677
|
+
* object first (returning `false` when not found) before calling this method.
|
|
1678
|
+
*
|
|
1679
|
+
* @returns Promise that resolves when deletion is complete
|
|
1680
|
+
*
|
|
1681
|
+
* @example
|
|
1682
|
+
* ```typescript
|
|
1683
|
+
* const product = await products.get('product-uuid');
|
|
1684
|
+
* if (product) await product.delete();
|
|
1685
|
+
* ```
|
|
1686
|
+
*/
|
|
1687
|
+
async delete() {
|
|
1688
|
+
const interceptorContext = createInterceptorContext(
|
|
1689
|
+
this.constructor.name,
|
|
1690
|
+
"delete"
|
|
1691
|
+
);
|
|
1692
|
+
await GlobalInterceptors.executeBeforeDelete(this, interceptorContext);
|
|
1693
|
+
await this.runHook("beforeDelete");
|
|
1694
|
+
await this.verifyStorageReady();
|
|
1695
|
+
await this.db.delete(this.tableName, { id: this.id });
|
|
1696
|
+
this._persisted = false;
|
|
1697
|
+
this.invalidateCollectionReadCache();
|
|
1698
|
+
await this.runHook("afterDelete");
|
|
1699
|
+
await GlobalInterceptors.executeAfterDelete(this, interceptorContext);
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Invalidate cached collection reads after a successful mutation
|
|
1703
|
+
* (issue #1498).
|
|
1704
|
+
*
|
|
1705
|
+
* Always drops this table's in-process entries — a no-op when nothing
|
|
1706
|
+
* opted into caching. When the table is cached cross-process (see
|
|
1707
|
+
* {@link shouldBroadcastCacheInvalidation}), the invalidation is also
|
|
1708
|
+
* broadcast to peer replicas over the database adapter's notification
|
|
1709
|
+
* capability, fire-and-forget. Cache maintenance must never fail the
|
|
1710
|
+
* write that triggered it.
|
|
1711
|
+
*/
|
|
1712
|
+
invalidateCollectionReadCache() {
|
|
1713
|
+
try {
|
|
1714
|
+
const dbKey = resolveDbCacheKey(this.db);
|
|
1715
|
+
invalidateCollectionCache(dbKey, this.tableName);
|
|
1716
|
+
if (this.shouldBroadcastCacheInvalidation(dbKey)) {
|
|
1717
|
+
void broadcastCacheInvalidation(this.db, this.tableName);
|
|
1718
|
+
}
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
logger.warn("Failed to invalidate collection read cache after write", {
|
|
1721
|
+
table: this.tableName,
|
|
1722
|
+
error: error instanceof Error ? error.message : error
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Decide whether a mutation should broadcast a cross-process cache
|
|
1728
|
+
* invalidation. Invalidation is table-scoped, so the decision must be too:
|
|
1729
|
+
*
|
|
1730
|
+
* 1. A per-call `crossProcess` cached read in this process registered
|
|
1731
|
+
* interest in the table — broadcast even without model-level config.
|
|
1732
|
+
* 2. This class's resolved `@smrt({ cache })` config sets `crossProcess`.
|
|
1733
|
+
* 3. Any other STI hierarchy member sharing the table resolves to a
|
|
1734
|
+
* `crossProcess` config — a child that opted out with `cache: false`
|
|
1735
|
+
* still mutates the shared table its base/siblings are caching.
|
|
1736
|
+
*/
|
|
1737
|
+
shouldBroadcastCacheInvalidation(dbKey) {
|
|
1738
|
+
if (hasCrossProcessCacheInterest(dbKey, this.tableName)) {
|
|
1739
|
+
return true;
|
|
1740
|
+
}
|
|
1741
|
+
const qualifiedName = this.getResolvedQualifiedName();
|
|
1742
|
+
if (ObjectRegistry.resolveCollectionCacheConfig(qualifiedName)?.crossProcess) {
|
|
1743
|
+
return true;
|
|
1744
|
+
}
|
|
1745
|
+
for (const member of getSTIHierarchyMembers(qualifiedName)) {
|
|
1746
|
+
if (member === qualifiedName) continue;
|
|
1747
|
+
if (ObjectRegistry.resolveCollectionCacheConfig(member)?.crossProcess) {
|
|
1748
|
+
return true;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
return false;
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Check if a relationship has been loaded
|
|
1755
|
+
*
|
|
1756
|
+
* @param fieldName - Name of the relationship field
|
|
1757
|
+
* @returns True if the relationship is loaded, false otherwise
|
|
1758
|
+
* @example
|
|
1759
|
+
* ```typescript
|
|
1760
|
+
* if (order.isRelatedLoaded('customer')) {
|
|
1761
|
+
* console.log('Customer already loaded');
|
|
1762
|
+
* }
|
|
1763
|
+
* ```
|
|
1764
|
+
*/
|
|
1765
|
+
isRelatedLoaded(fieldName) {
|
|
1766
|
+
return this._loadedRelationships.has(fieldName);
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Lazy-loads a `foreignKey` or `crossPackageRef` relationship and caches the
|
|
1770
|
+
* result.
|
|
1771
|
+
*
|
|
1772
|
+
* Looks up the relationship metadata in the ObjectRegistry, reads the
|
|
1773
|
+
* foreign key value on this object, and fetches the related object from
|
|
1774
|
+
* the database. Subsequent calls return the cached value without hitting
|
|
1775
|
+
* the database again.
|
|
1776
|
+
*
|
|
1777
|
+
* For STI relationships, the correct subclass is determined by reading
|
|
1778
|
+
* `_meta_type` from the database row before instantiation.
|
|
1779
|
+
*
|
|
1780
|
+
* Enforces tenant isolation: if this object and the loaded target both carry
|
|
1781
|
+
* a non-null `tenantId` that differ, a {@link TenantIsolationError} is thrown
|
|
1782
|
+
* (unless `opts.allowCrossTenant` is set). The check is a no-op for
|
|
1783
|
+
* global/null-tenant models and same-tenant loads, so it only catches genuine
|
|
1784
|
+
* cross-tenant leaks (Issue #1321).
|
|
1785
|
+
*
|
|
1786
|
+
* @param fieldName - Name of the `@foreignKey()` or `@crossPackageRef()`
|
|
1787
|
+
* decorated property
|
|
1788
|
+
* @param opts - Optional loader options; see {@link LoadRelatedOptions}
|
|
1789
|
+
* @returns The related object, or `null` if the foreign key is empty
|
|
1790
|
+
* @throws {RuntimeError} If `fieldName` is not a `foreignKey` or
|
|
1791
|
+
* `crossPackageRef` relationship, or the target class is not found in the
|
|
1792
|
+
* ObjectRegistry
|
|
1793
|
+
* @throws {TenantIsolationError} If the target belongs to a different,
|
|
1794
|
+
* non-null tenant and `opts.allowCrossTenant` is not set
|
|
1795
|
+
*
|
|
1796
|
+
* @example
|
|
1797
|
+
* ```typescript
|
|
1798
|
+
* // class Order { @foreignKey(Customer) customerId: string = ''; }
|
|
1799
|
+
* const customer = await order.loadRelated('customerId');
|
|
1800
|
+
* console.log(customer?.name);
|
|
1801
|
+
* ```
|
|
1802
|
+
*
|
|
1803
|
+
* @see {@link getRelated} for a convenience wrapper that auto-detects relationship type
|
|
1804
|
+
*/
|
|
1805
|
+
async loadRelated(fieldName, opts) {
|
|
1806
|
+
if (this._loadedRelationships.has(fieldName)) {
|
|
1807
|
+
const cached = this._loadedRelationships.get(fieldName);
|
|
1808
|
+
this.assertRelatedTenant(cached, fieldName, opts?.allowCrossTenant);
|
|
1809
|
+
return cached;
|
|
1810
|
+
}
|
|
1811
|
+
await ObjectRegistry.ensureManifestLoaded(this.constructor.name);
|
|
1812
|
+
const relationships = ObjectRegistry.getRelationships(
|
|
1813
|
+
this.constructor.name
|
|
1814
|
+
);
|
|
1815
|
+
const relationship = relationships.find(
|
|
1816
|
+
(r) => r.fieldName === fieldName && (r.type === "foreignKey" || r.type === "crossPackageRef")
|
|
1817
|
+
);
|
|
1818
|
+
if (!relationship) {
|
|
1819
|
+
throw RuntimeError.invalidState(
|
|
1820
|
+
`Field ${fieldName} is not a foreignKey or crossPackageRef relationship on ${this.constructor.name}`,
|
|
1821
|
+
{ fieldName, className: this.constructor.name }
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
const foreignKeyValue = this[fieldName];
|
|
1825
|
+
if (!foreignKeyValue) {
|
|
1826
|
+
this._loadedRelationships.set(fieldName, null);
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
if (relationship.type === "crossPackageRef") {
|
|
1830
|
+
await ObjectRegistry.ensureManifestLoaded(relationship.targetClass);
|
|
1831
|
+
}
|
|
1832
|
+
const targetClassInfo = relationship.type === "crossPackageRef" ? ObjectRegistry.getClassByQualifiedName(relationship.targetClass) ?? ObjectRegistry.getClass(relationship.targetClass) : ObjectRegistry.getClass(relationship.targetClass);
|
|
1833
|
+
if (!targetClassInfo) {
|
|
1834
|
+
throw RuntimeError.invalidState(
|
|
1835
|
+
`Target class ${relationship.targetClass} not found in ObjectRegistry`,
|
|
1836
|
+
{ targetClass: relationship.targetClass, fieldName }
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
const tableStrategy = ObjectRegistry.getTableStrategy(
|
|
1840
|
+
relationship.targetClass
|
|
1841
|
+
);
|
|
1842
|
+
const isSTI = tableStrategy === "sti";
|
|
1843
|
+
let actualClassInfo = targetClassInfo;
|
|
1844
|
+
if (isSTI) {
|
|
1845
|
+
const tempInstance = new targetClassInfo.constructor(this.options);
|
|
1846
|
+
await tempInstance.initialize();
|
|
1847
|
+
await tempInstance.verifyStorageReady();
|
|
1848
|
+
const row = await tempInstance.db.get(tempInstance.tableName, {
|
|
1849
|
+
id: foreignKeyValue
|
|
1850
|
+
});
|
|
1851
|
+
if (row?._meta_type) {
|
|
1852
|
+
let actualClass = ObjectRegistry.getClassByQualifiedName(
|
|
1853
|
+
row._meta_type
|
|
1854
|
+
);
|
|
1855
|
+
if (!actualClass) {
|
|
1856
|
+
actualClass = ObjectRegistry.getClass(row._meta_type);
|
|
1857
|
+
}
|
|
1858
|
+
if (actualClass) {
|
|
1859
|
+
actualClassInfo = actualClass;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
const relatedInstance = new actualClassInfo.constructor(this.options);
|
|
1864
|
+
await relatedInstance.initialize();
|
|
1865
|
+
relatedInstance.id = foreignKeyValue;
|
|
1866
|
+
await relatedInstance.loadFromId();
|
|
1867
|
+
this.assertRelatedTenant(
|
|
1868
|
+
relatedInstance,
|
|
1869
|
+
fieldName,
|
|
1870
|
+
opts?.allowCrossTenant
|
|
1871
|
+
);
|
|
1872
|
+
this._loadedRelationships.set(fieldName, relatedInstance);
|
|
1873
|
+
return relatedInstance;
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Guards a loaded relationship target against cross-tenant access.
|
|
1877
|
+
*
|
|
1878
|
+
* Throws {@link TenantIsolationError} when this object and `loaded` both carry
|
|
1879
|
+
* a non-null `tenantId` that differ — the genuine cross-tenant leak. It is a
|
|
1880
|
+
* no-op when `allowCrossTenant === true`, when `loaded` is `null`, when either
|
|
1881
|
+
* side has a `null`/`undefined` tenant (global / non-tenant-scoped models),
|
|
1882
|
+
* and when both sides share the same tenant (Issue #1321).
|
|
1883
|
+
*
|
|
1884
|
+
* `loaded` may be a single related object or an array (oneToMany / manyToMany);
|
|
1885
|
+
* arrays are validated per item. This runs on both fresh loads and cache hits,
|
|
1886
|
+
* so a cache populated by an eager `include` load — or by a prior
|
|
1887
|
+
* `allowCrossTenant` load — cannot leak cross-tenant data through a later
|
|
1888
|
+
* guarded call.
|
|
1889
|
+
*
|
|
1890
|
+
* @param loaded - The loaded related object, or array of objects.
|
|
1891
|
+
* @param fieldName - The relationship field name (for error context).
|
|
1892
|
+
* @param allowCrossTenant - When exactly `true`, bypasses the guard entirely.
|
|
1893
|
+
*/
|
|
1894
|
+
assertRelatedTenant(loaded, fieldName, allowCrossTenant) {
|
|
1895
|
+
if (allowCrossTenant === true || loaded == null) {
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
if (Array.isArray(loaded)) {
|
|
1899
|
+
for (const item of loaded) {
|
|
1900
|
+
this.assertRelatedTenant(item, fieldName, allowCrossTenant);
|
|
1901
|
+
}
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
const sourceTenantId = this.tenantId;
|
|
1905
|
+
const targetTenantId = loaded.tenantId;
|
|
1906
|
+
if (sourceTenantId != null && targetTenantId != null && sourceTenantId !== targetTenantId) {
|
|
1907
|
+
throw TenantIsolationError.crossTenantReference({
|
|
1908
|
+
sourceClass: this.constructor.name,
|
|
1909
|
+
fieldName,
|
|
1910
|
+
sourceTenantId: String(sourceTenantId),
|
|
1911
|
+
targetClass: loaded?.constructor?.name,
|
|
1912
|
+
targetTenantId: String(targetTenantId)
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Load related objects for oneToMany or manyToMany fields (lazy loading)
|
|
1918
|
+
*
|
|
1919
|
+
* Loads all related objects from the database. For oneToMany, queries by
|
|
1920
|
+
* the inverse foreign key. For manyToMany, queries through the join table.
|
|
1921
|
+
*
|
|
1922
|
+
* Enforces tenant isolation per item: if this object and a loaded target both
|
|
1923
|
+
* carry a non-null `tenantId` that differ, a {@link TenantIsolationError} is
|
|
1924
|
+
* thrown (unless `opts.allowCrossTenant` is set). The check is a no-op for
|
|
1925
|
+
* global/null-tenant models and same-tenant loads, so it only catches genuine
|
|
1926
|
+
* cross-tenant leaks (Issue #1321).
|
|
1927
|
+
*
|
|
1928
|
+
* @param fieldName - Name of the oneToMany or manyToMany field
|
|
1929
|
+
* @param opts - Optional loader options; see {@link LoadRelatedOptions}
|
|
1930
|
+
* @returns Promise resolving to array of related objects
|
|
1931
|
+
* @throws {RuntimeError} If the field is not a relationship or not implemented
|
|
1932
|
+
* @throws {TenantIsolationError} If any loaded item belongs to a different,
|
|
1933
|
+
* non-null tenant and `opts.allowCrossTenant` is not set
|
|
1934
|
+
* @example
|
|
1935
|
+
* ```typescript
|
|
1936
|
+
* // Given: class Customer with orders = oneToMany(Order)
|
|
1937
|
+
* const orders = await customer.loadRelatedMany('orders');
|
|
1938
|
+
* console.log(`${orders.length} orders found`);
|
|
1939
|
+
* ```
|
|
1940
|
+
*/
|
|
1941
|
+
async loadRelatedMany(fieldName, opts) {
|
|
1942
|
+
if (this._loadedRelationships.has(fieldName)) {
|
|
1943
|
+
const cached = this._loadedRelationships.get(fieldName);
|
|
1944
|
+
this.assertRelatedTenant(cached, fieldName, opts?.allowCrossTenant);
|
|
1945
|
+
return cached;
|
|
1946
|
+
}
|
|
1947
|
+
await ObjectRegistry.ensureManifestLoaded(this.constructor.name);
|
|
1948
|
+
const relationships = ObjectRegistry.getRelationships(
|
|
1949
|
+
this.constructor.name
|
|
1950
|
+
);
|
|
1951
|
+
const relationship = relationships.find((r) => r.fieldName === fieldName);
|
|
1952
|
+
if (!relationship) {
|
|
1953
|
+
throw RuntimeError.invalidState(
|
|
1954
|
+
`Field ${fieldName} is not a relationship on ${this.constructor.name}`,
|
|
1955
|
+
{ fieldName, className: this.constructor.name }
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
if (relationship.type === "oneToMany") {
|
|
1959
|
+
const inverseRelationships = ObjectRegistry.getInverseRelationshipsForSelf(this.constructor.name);
|
|
1960
|
+
const inverseCandidates = inverseRelationships.filter(
|
|
1961
|
+
(r) => r.sourceClass === relationship.targetClass && r.type === "foreignKey"
|
|
1962
|
+
);
|
|
1963
|
+
const explicitForeignKey = relationship.options?.foreignKey;
|
|
1964
|
+
const matchedForeignKey = explicitForeignKey ? inverseCandidates.find((r) => r.fieldName === explicitForeignKey) : void 0;
|
|
1965
|
+
if (explicitForeignKey && !matchedForeignKey) {
|
|
1966
|
+
throw RuntimeError.invalidState(
|
|
1967
|
+
`oneToMany ${fieldName} on ${this.constructor.name} specifies foreignKey '${explicitForeignKey}', but ${relationship.targetClass} has no matching inverse foreignKey. Candidates: ${inverseCandidates.map((r) => r.fieldName).join(", ") || "(none)"}`,
|
|
1968
|
+
{
|
|
1969
|
+
fieldName,
|
|
1970
|
+
targetClass: relationship.targetClass,
|
|
1971
|
+
foreignKey: explicitForeignKey
|
|
1972
|
+
}
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
const inverseForeignKey = matchedForeignKey ?? inverseCandidates.find(
|
|
1976
|
+
(r) => r.targetClass === this.constructor.name
|
|
1977
|
+
) ?? inverseCandidates[0];
|
|
1978
|
+
if (!inverseForeignKey) {
|
|
1979
|
+
throw RuntimeError.invalidState(
|
|
1980
|
+
`Could not find inverse foreignKey on ${relationship.targetClass} for oneToMany relationship ${fieldName}`,
|
|
1981
|
+
{ fieldName, targetClass: relationship.targetClass }
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
const collection = await ObjectRegistry.getCollection(
|
|
1985
|
+
relationship.targetClass,
|
|
1986
|
+
this.options
|
|
1987
|
+
);
|
|
1988
|
+
const relatedObjects = await collection.list({
|
|
1989
|
+
where: { [inverseForeignKey.fieldName]: this.id }
|
|
1990
|
+
});
|
|
1991
|
+
this.assertRelatedTenant(
|
|
1992
|
+
relatedObjects,
|
|
1993
|
+
fieldName,
|
|
1994
|
+
opts?.allowCrossTenant
|
|
1995
|
+
);
|
|
1996
|
+
this._loadedRelationships.set(fieldName, relatedObjects);
|
|
1997
|
+
return relatedObjects;
|
|
1998
|
+
}
|
|
1999
|
+
if (relationship.type === "manyToMany") {
|
|
2000
|
+
const { through, sourceColumn, targetColumn, targetClassName } = await this.resolveManyToManyJoin(fieldName, relationship);
|
|
2001
|
+
if (!this.id) {
|
|
2002
|
+
this._loadedRelationships.set(fieldName, []);
|
|
2003
|
+
return [];
|
|
2004
|
+
}
|
|
2005
|
+
await this.verifyStorageReady();
|
|
2006
|
+
const junctionRows = await this.db.query(
|
|
2007
|
+
`SELECT "${targetColumn}" FROM "${through}" WHERE "${sourceColumn}" = ?`,
|
|
2008
|
+
[this.id]
|
|
2009
|
+
);
|
|
2010
|
+
const targetIds = junctionRows.rows.map((row) => row[targetColumn]).filter(
|
|
2011
|
+
(id) => typeof id === "string" && id.length > 0
|
|
2012
|
+
);
|
|
2013
|
+
if (targetIds.length === 0) {
|
|
2014
|
+
this._loadedRelationships.set(fieldName, []);
|
|
2015
|
+
return [];
|
|
2016
|
+
}
|
|
2017
|
+
const targetCollection = await ObjectRegistry.getCollection(
|
|
2018
|
+
targetClassName,
|
|
2019
|
+
this.options
|
|
2020
|
+
);
|
|
2021
|
+
const targetObjects = await targetCollection.list({
|
|
2022
|
+
where: { "id in": targetIds }
|
|
2023
|
+
});
|
|
2024
|
+
this.assertRelatedTenant(
|
|
2025
|
+
targetObjects,
|
|
2026
|
+
fieldName,
|
|
2027
|
+
opts?.allowCrossTenant
|
|
2028
|
+
);
|
|
2029
|
+
this._loadedRelationships.set(fieldName, targetObjects);
|
|
2030
|
+
return targetObjects;
|
|
2031
|
+
}
|
|
2032
|
+
throw RuntimeError.invalidState(
|
|
2033
|
+
`Field ${fieldName} is not a oneToMany or manyToMany relationship`,
|
|
2034
|
+
{ fieldName, type: relationship.type }
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Resolves the junction-table coordinates for a manyToMany relationship.
|
|
2039
|
+
*
|
|
2040
|
+
* Discovers `through`, source-side column, and target-side column from the
|
|
2041
|
+
* registered field metadata. Falls back to convention (`<class>_id`) when
|
|
2042
|
+
* `sourceKey` / `targetKey` are not explicitly set.
|
|
2043
|
+
*/
|
|
2044
|
+
async resolveManyToManyJoin(fieldName, relationship) {
|
|
2045
|
+
const decorator = ObjectRegistry.getFieldDecorator(
|
|
2046
|
+
relationship.sourceClass,
|
|
2047
|
+
fieldName
|
|
2048
|
+
);
|
|
2049
|
+
const opts = relationship.options || {};
|
|
2050
|
+
const through = decorator?.through ?? opts.through ?? opts._meta?.through;
|
|
2051
|
+
if (!through) {
|
|
2052
|
+
throw RuntimeError.invalidState(
|
|
2053
|
+
`manyToMany field ${fieldName} on ${relationship.sourceClass} is missing the 'through' join table name`,
|
|
2054
|
+
{ fieldName, type: "manyToMany" }
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
const targetSimpleName = relationship.targetClass.includes(":") ? relationship.targetClass.split(":").pop() : relationship.targetClass;
|
|
2058
|
+
const sourceSimpleName = relationship.sourceClass.includes(":") ? relationship.sourceClass.split(":").pop() : relationship.sourceClass;
|
|
2059
|
+
const sourceColumn = decorator?.sourceKey ?? opts.sourceKey ?? opts._meta?.sourceKey ?? `${toSnakeCase(sourceSimpleName)}_id`;
|
|
2060
|
+
const targetColumn = decorator?.targetKey ?? opts.targetKey ?? opts._meta?.targetKey ?? `${toSnakeCase(targetSimpleName)}_id`;
|
|
2061
|
+
return {
|
|
2062
|
+
through: String(through),
|
|
2063
|
+
sourceColumn,
|
|
2064
|
+
targetColumn,
|
|
2065
|
+
targetClassName: relationship.targetClass
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Get a related object, loading it if not already loaded
|
|
2070
|
+
*
|
|
2071
|
+
* Convenience method that checks if the relationship is loaded and
|
|
2072
|
+
* loads it if necessary. Automatically detects foreignKey vs oneToMany/manyToMany.
|
|
2073
|
+
*
|
|
2074
|
+
* Tenant isolation is enforced by the underlying loaders; see
|
|
2075
|
+
* {@link loadRelated} and {@link loadRelatedMany}.
|
|
2076
|
+
*
|
|
2077
|
+
* @param fieldName - Name of the relationship field
|
|
2078
|
+
* @param opts - Optional loader options; see {@link LoadRelatedOptions}
|
|
2079
|
+
* @returns Promise resolving to the related object(s)
|
|
2080
|
+
* @throws {TenantIsolationError} If a loaded target belongs to a different,
|
|
2081
|
+
* non-null tenant and `opts.allowCrossTenant` is not set
|
|
2082
|
+
* @example
|
|
2083
|
+
* ```typescript
|
|
2084
|
+
* // Loads customer if not already loaded
|
|
2085
|
+
* const customer = await order.getRelated('customerId');
|
|
2086
|
+
*
|
|
2087
|
+
* // Loads orders if not already loaded
|
|
2088
|
+
* const orders = await customer.getRelated('orders');
|
|
2089
|
+
* ```
|
|
2090
|
+
*/
|
|
2091
|
+
async getRelated(fieldName, opts) {
|
|
2092
|
+
if (this._loadedRelationships.has(fieldName)) {
|
|
2093
|
+
const cached = this._loadedRelationships.get(fieldName);
|
|
2094
|
+
this.assertRelatedTenant(cached, fieldName, opts?.allowCrossTenant);
|
|
2095
|
+
return cached;
|
|
2096
|
+
}
|
|
2097
|
+
await ObjectRegistry.ensureManifestLoaded(this.constructor.name);
|
|
2098
|
+
const relationships = ObjectRegistry.getRelationships(
|
|
2099
|
+
this.constructor.name
|
|
2100
|
+
);
|
|
2101
|
+
const relationship = relationships.find((r) => r.fieldName === fieldName);
|
|
2102
|
+
if (!relationship) {
|
|
2103
|
+
throw RuntimeError.invalidState(
|
|
2104
|
+
`Field ${fieldName} is not a relationship on ${this.constructor.name}`,
|
|
2105
|
+
{ fieldName, className: this.constructor.name }
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
if (relationship.type === "foreignKey" || relationship.type === "crossPackageRef") {
|
|
2109
|
+
return this.loadRelated(fieldName, opts);
|
|
2110
|
+
}
|
|
2111
|
+
return this.loadRelatedMany(fieldName, opts);
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Get available AI-callable tools for this object
|
|
2115
|
+
*
|
|
2116
|
+
* Returns the pre-generated tool definitions from the manifest.
|
|
2117
|
+
* Tools are generated at build time based on the @smrt decorator's AI config.
|
|
2118
|
+
*
|
|
2119
|
+
* @returns Array of AITool definitions for LLM function calling
|
|
2120
|
+
* @example
|
|
2121
|
+
* ```typescript
|
|
2122
|
+
* const tools = document.getAvailableTools();
|
|
2123
|
+
* console.log(`${tools.length} AI-callable methods available`);
|
|
2124
|
+
* ```
|
|
2125
|
+
*/
|
|
2126
|
+
getAvailableTools() {
|
|
2127
|
+
const classInfo = ObjectRegistry.getClass(this.constructor.name);
|
|
2128
|
+
return classInfo?.tools || [];
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Execute a tool call from AI on this object instance
|
|
2132
|
+
*
|
|
2133
|
+
* Validates the tool call against allowed methods and executes it with
|
|
2134
|
+
* proper error handling and timing.
|
|
2135
|
+
*
|
|
2136
|
+
* @param toolCall - Tool call from AI response
|
|
2137
|
+
* @returns Promise resolving to the tool call result
|
|
2138
|
+
* @example
|
|
2139
|
+
* ```typescript
|
|
2140
|
+
* const toolCall = {
|
|
2141
|
+
* id: 'call_123',
|
|
2142
|
+
* type: 'function',
|
|
2143
|
+
* function: {
|
|
2144
|
+
* name: 'analyze',
|
|
2145
|
+
* arguments: '{"type": "detailed"}'
|
|
2146
|
+
* }
|
|
2147
|
+
* };
|
|
2148
|
+
*
|
|
2149
|
+
* const result = await document.executeToolCall(toolCall);
|
|
2150
|
+
* console.log(result.success ? result.result : result.error);
|
|
2151
|
+
* ```
|
|
2152
|
+
*/
|
|
2153
|
+
async executeToolCall(toolCall) {
|
|
2154
|
+
const tools = this.getAvailableTools();
|
|
2155
|
+
const allowedMethods = tools.map((tool) => tool.function.name);
|
|
2156
|
+
return executeToolCall(this, toolCall, allowedMethods);
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Stores a named value in the `_smrt_contexts` system table, scoped to this object.
|
|
2160
|
+
*
|
|
2161
|
+
* Context entries are keyed by `(owner_class, owner_id, scope, key, version)` and
|
|
2162
|
+
* support an optional `confidence` score (0–1, default 1.0) for learned patterns.
|
|
2163
|
+
* Calling `remember()` with the same scope+key upserts the existing entry.
|
|
2164
|
+
*
|
|
2165
|
+
* Requires `initialize()` to have been called (needs `this.systemDb`).
|
|
2166
|
+
*
|
|
2167
|
+
* @param options.id - Optional explicit ID for the context entry (auto-generated if omitted)
|
|
2168
|
+
* @param options.scope - Hierarchical path scoping the context (e.g. `'parser/example.com'`)
|
|
2169
|
+
* @param options.key - Lookup key within the scope (e.g. a normalized URL)
|
|
2170
|
+
* @param options.value - Any JSON-serializable value to store
|
|
2171
|
+
* @param options.metadata - Optional additional JSON metadata
|
|
2172
|
+
* @param options.confidence - Confidence score (0–1, default 1.0)
|
|
2173
|
+
* @param options.version - Schema version for the stored value (default 1)
|
|
2174
|
+
* @param options.expiresAt - Optional expiry date after which `recall()` will ignore this entry
|
|
2175
|
+
* @returns Promise that resolves when the context is stored
|
|
2176
|
+
* @throws Error if `initialize()` has not been called
|
|
2177
|
+
*
|
|
2178
|
+
* @example
|
|
2179
|
+
* ```typescript
|
|
2180
|
+
* await agent.remember({
|
|
2181
|
+
* scope: 'parser/example.com',
|
|
2182
|
+
* key: normalizedUrl,
|
|
2183
|
+
* value: { patterns: ['regex1', 'regex2'] },
|
|
2184
|
+
* confidence: 0.9,
|
|
2185
|
+
* });
|
|
2186
|
+
* ```
|
|
2187
|
+
*
|
|
2188
|
+
* @see {@link recall} to retrieve a single entry
|
|
2189
|
+
* @see {@link recallAll} to retrieve all entries in a scope
|
|
2190
|
+
* @see {@link forget} to delete a specific entry
|
|
2191
|
+
*/
|
|
2192
|
+
async remember(options) {
|
|
2193
|
+
if (!this.systemDb) {
|
|
2194
|
+
throw new Error("Database not initialized. Call initialize() first.");
|
|
2195
|
+
}
|
|
2196
|
+
const id = options.id || crypto.randomUUID();
|
|
2197
|
+
const now = /* @__PURE__ */ new Date();
|
|
2198
|
+
if (!this.id) {
|
|
2199
|
+
this._id = crypto.randomUUID();
|
|
2200
|
+
}
|
|
2201
|
+
await this.systemDb.upsert(
|
|
2202
|
+
"_smrt_contexts",
|
|
2203
|
+
["owner_class", "owner_id", "scope", "key", "version"],
|
|
2204
|
+
{
|
|
2205
|
+
id,
|
|
2206
|
+
owner_class: this._className,
|
|
2207
|
+
owner_id: this.id,
|
|
2208
|
+
scope: options.scope,
|
|
2209
|
+
key: options.key,
|
|
2210
|
+
value: JSON.stringify(options.value),
|
|
2211
|
+
metadata: options.metadata ? JSON.stringify(options.metadata) : null,
|
|
2212
|
+
version: options.version ?? 1,
|
|
2213
|
+
confidence: options.confidence ?? 1,
|
|
2214
|
+
created_at: now,
|
|
2215
|
+
updated_at: now,
|
|
2216
|
+
last_used_at: now,
|
|
2217
|
+
expires_at: options.expiresAt ?? null
|
|
2218
|
+
}
|
|
2219
|
+
);
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Retrieves a single remembered context value for this object.
|
|
2223
|
+
*
|
|
2224
|
+
* Looks up the most recent (highest confidence, then highest version) entry
|
|
2225
|
+
* matching `(owner_class, owner_id, scope, key)`. Returns the JSON-parsed value
|
|
2226
|
+
* or `null` if no matching entry exists.
|
|
2227
|
+
*
|
|
2228
|
+
* When `includeAncestors: true`, walks up the scope hierarchy by progressively
|
|
2229
|
+
* removing the last path segment (e.g. `'a/b/c'` → `'a/b'` → `'a'` → `'global'`)
|
|
2230
|
+
* until a match is found.
|
|
2231
|
+
*
|
|
2232
|
+
* @param options.scope - Scope path to search (e.g. `'parser/example.com/article'`)
|
|
2233
|
+
* @param options.key - Lookup key within the scope
|
|
2234
|
+
* @param options.includeAncestors - If `true`, fall back to parent scopes (default `false`)
|
|
2235
|
+
* @param options.minConfidence - Only return entries at or above this confidence (0–1)
|
|
2236
|
+
* @returns The stored value (parsed from JSON), or `null` if not found
|
|
2237
|
+
* @throws Error if `initialize()` has not been called
|
|
2238
|
+
*
|
|
2239
|
+
* @example
|
|
2240
|
+
* ```typescript
|
|
2241
|
+
* const strategy = await agent.recall({
|
|
2242
|
+
* scope: 'parser/example.com/article',
|
|
2243
|
+
* key: normalizedUrl,
|
|
2244
|
+
* includeAncestors: true,
|
|
2245
|
+
* minConfidence: 0.6,
|
|
2246
|
+
* });
|
|
2247
|
+
* ```
|
|
2248
|
+
*
|
|
2249
|
+
* @see {@link remember} to store a context entry
|
|
2250
|
+
* @see {@link recallAll} to retrieve all entries in a scope
|
|
2251
|
+
*/
|
|
2252
|
+
async recall(options) {
|
|
2253
|
+
if (!this.systemDb) {
|
|
2254
|
+
throw new Error("Database not initialized. Call initialize() first.");
|
|
2255
|
+
}
|
|
2256
|
+
let result;
|
|
2257
|
+
if (options.minConfidence !== void 0) {
|
|
2258
|
+
result = await this.systemDb.single`
|
|
2259
|
+
SELECT value, confidence
|
|
2260
|
+
FROM _smrt_contexts
|
|
2261
|
+
WHERE owner_class = ${this._className}
|
|
2262
|
+
AND owner_id = ${this.id}
|
|
2263
|
+
AND scope = ${options.scope}
|
|
2264
|
+
AND key = ${options.key}
|
|
2265
|
+
AND confidence >= ${options.minConfidence}
|
|
2266
|
+
ORDER BY confidence DESC, version DESC
|
|
2267
|
+
LIMIT 1
|
|
2268
|
+
`;
|
|
2269
|
+
} else {
|
|
2270
|
+
result = await this.systemDb.single`
|
|
2271
|
+
SELECT value, confidence
|
|
2272
|
+
FROM _smrt_contexts
|
|
2273
|
+
WHERE owner_class = ${this._className}
|
|
2274
|
+
AND owner_id = ${this.id}
|
|
2275
|
+
AND scope = ${options.scope}
|
|
2276
|
+
AND key = ${options.key}
|
|
2277
|
+
ORDER BY confidence DESC, version DESC
|
|
2278
|
+
LIMIT 1
|
|
2279
|
+
`;
|
|
2280
|
+
}
|
|
2281
|
+
if (result) {
|
|
2282
|
+
try {
|
|
2283
|
+
return JSON.parse(result.value);
|
|
2284
|
+
} catch (error) {
|
|
2285
|
+
logger.warn("Skipping corrupted _smrt_contexts value in recall()", {
|
|
2286
|
+
ownerClass: this._className,
|
|
2287
|
+
ownerId: this.id,
|
|
2288
|
+
scope: options.scope,
|
|
2289
|
+
key: options.key,
|
|
2290
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
if (options.includeAncestors) {
|
|
2295
|
+
const scopeParts = options.scope.split("/");
|
|
2296
|
+
while (scopeParts.length > 0) {
|
|
2297
|
+
scopeParts.pop();
|
|
2298
|
+
const parentScope = scopeParts.join("/") || "global";
|
|
2299
|
+
const parentResult = await this.recall({
|
|
2300
|
+
...options,
|
|
2301
|
+
scope: parentScope,
|
|
2302
|
+
includeAncestors: false
|
|
2303
|
+
});
|
|
2304
|
+
if (parentResult) return parentResult;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
return null;
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Retrieves all remembered context entries for this object in a scope.
|
|
2311
|
+
*
|
|
2312
|
+
* Returns a `Map<key, value>` for every entry owned by this object that matches
|
|
2313
|
+
* the scope and optional filters. When `includeDescendants: true`, a LIKE query
|
|
2314
|
+
* (`scope%`) matches the scope itself and all child scopes.
|
|
2315
|
+
*
|
|
2316
|
+
* @param options.scope - Optional scope path filter; omit to retrieve all scopes
|
|
2317
|
+
* @param options.includeDescendants - If `true`, match `scope` and all child scopes (default `false`)
|
|
2318
|
+
* @param options.minConfidence - Only include entries at or above this confidence (0–1)
|
|
2319
|
+
* @returns `Map<string, any>` of key → JSON-parsed value pairs
|
|
2320
|
+
* @throws Error if `initialize()` has not been called
|
|
2321
|
+
*
|
|
2322
|
+
* @example
|
|
2323
|
+
* ```typescript
|
|
2324
|
+
* const strategies = await agent.recallAll({
|
|
2325
|
+
* scope: 'parser/example.com',
|
|
2326
|
+
* minConfidence: 0.5,
|
|
2327
|
+
* });
|
|
2328
|
+
* for (const [url, pattern] of strategies) {
|
|
2329
|
+
* console.log(`Cached pattern for ${url}:`, pattern);
|
|
2330
|
+
* }
|
|
2331
|
+
* ```
|
|
2332
|
+
*
|
|
2333
|
+
* @see {@link recall} to retrieve a single entry by key
|
|
2334
|
+
* @see {@link forgetScope} to delete all entries in a scope
|
|
2335
|
+
*/
|
|
2336
|
+
async recallAll(options = {}) {
|
|
2337
|
+
if (!this.systemDb) {
|
|
2338
|
+
throw new Error("Database not initialized. Call initialize() first.");
|
|
2339
|
+
}
|
|
2340
|
+
const results = /* @__PURE__ */ new Map();
|
|
2341
|
+
const where = {
|
|
2342
|
+
owner_class: this._className,
|
|
2343
|
+
owner_id: this.id
|
|
2344
|
+
};
|
|
2345
|
+
if (options.scope) {
|
|
2346
|
+
if (options.includeDescendants) {
|
|
2347
|
+
where["scope like"] = `${options.scope}%`;
|
|
2348
|
+
} else {
|
|
2349
|
+
where.scope = options.scope;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
if (options.minConfidence !== void 0) {
|
|
2353
|
+
where["confidence >="] = options.minConfidence;
|
|
2354
|
+
}
|
|
2355
|
+
const rows = await this.systemDb.list("_smrt_contexts", where);
|
|
2356
|
+
for (const row of rows) {
|
|
2357
|
+
try {
|
|
2358
|
+
results.set(row.key, JSON.parse(row.value));
|
|
2359
|
+
} catch (error) {
|
|
2360
|
+
logger.warn("Skipping corrupted _smrt_contexts value in recallAll()", {
|
|
2361
|
+
ownerClass: this._className,
|
|
2362
|
+
ownerId: this.id,
|
|
2363
|
+
scope: options.scope,
|
|
2364
|
+
key: row.key,
|
|
2365
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return results;
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Deletes a specific remembered context entry for this object.
|
|
2373
|
+
*
|
|
2374
|
+
* Removes the entry matching `(owner_class, owner_id, scope, key)`. A no-op
|
|
2375
|
+
* if the entry does not exist.
|
|
2376
|
+
*
|
|
2377
|
+
* @param options.scope - Scope path of the entry to delete
|
|
2378
|
+
* @param options.key - Key of the entry to delete
|
|
2379
|
+
* @returns Promise that resolves when the entry is deleted
|
|
2380
|
+
* @throws Error if `initialize()` has not been called
|
|
2381
|
+
*
|
|
2382
|
+
* @example
|
|
2383
|
+
* ```typescript
|
|
2384
|
+
* await agent.forget({ scope: 'parser/example.com', key: normalizedUrl });
|
|
2385
|
+
* ```
|
|
2386
|
+
*
|
|
2387
|
+
* @see {@link forgetScope} to delete all entries in a scope at once
|
|
2388
|
+
* @see {@link remember} to store a context entry
|
|
2389
|
+
*/
|
|
2390
|
+
async forget(options) {
|
|
2391
|
+
if (!this.systemDb) {
|
|
2392
|
+
throw new Error("Database not initialized. Call initialize() first.");
|
|
2393
|
+
}
|
|
2394
|
+
await this.systemDb.delete("_smrt_contexts", {
|
|
2395
|
+
owner_class: this._className,
|
|
2396
|
+
owner_id: this.id,
|
|
2397
|
+
scope: options.scope,
|
|
2398
|
+
key: options.key
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Deletes all remembered context entries in a scope for this object.
|
|
2403
|
+
*
|
|
2404
|
+
* When `includeDescendants: true`, uses a LIKE query (`scope%`) that matches
|
|
2405
|
+
* the scope itself and all child scopes (e.g. `'parser/example.com'` also
|
|
2406
|
+
* deletes `'parser/example.com/article'`).
|
|
2407
|
+
*
|
|
2408
|
+
* @param options.scope - Scope path to clear
|
|
2409
|
+
* @param options.includeDescendants - If `true`, also delete all child scopes (default `false`)
|
|
2410
|
+
* @returns Number of context entries deleted
|
|
2411
|
+
* @throws Error if `initialize()` has not been called
|
|
2412
|
+
*
|
|
2413
|
+
* @example
|
|
2414
|
+
* ```typescript
|
|
2415
|
+
* const count = await agent.forgetScope({
|
|
2416
|
+
* scope: 'parser/example.com',
|
|
2417
|
+
* includeDescendants: true,
|
|
2418
|
+
* });
|
|
2419
|
+
* console.log(`Cleared ${count} cached strategies`);
|
|
2420
|
+
* ```
|
|
2421
|
+
*
|
|
2422
|
+
* @see {@link forget} to delete a single entry by key
|
|
2423
|
+
* @see {@link recallAll} to bulk-retrieve before clearing
|
|
2424
|
+
*/
|
|
2425
|
+
async forgetScope(options) {
|
|
2426
|
+
if (!this.systemDb) {
|
|
2427
|
+
throw new Error("Database not initialized. Call initialize() first.");
|
|
2428
|
+
}
|
|
2429
|
+
const where = {
|
|
2430
|
+
owner_class: this._className,
|
|
2431
|
+
owner_id: this.id
|
|
2432
|
+
};
|
|
2433
|
+
if (options.includeDescendants) {
|
|
2434
|
+
where["scope like"] = `${options.scope}%`;
|
|
2435
|
+
} else {
|
|
2436
|
+
where.scope = options.scope;
|
|
2437
|
+
}
|
|
2438
|
+
const result = await this.systemDb.delete("_smrt_contexts", where);
|
|
2439
|
+
return result.affected || 0;
|
|
2440
|
+
}
|
|
2441
|
+
// ============================================================================
|
|
2442
|
+
// Embedding Methods for Semantic Search
|
|
2443
|
+
// ============================================================================
|
|
2444
|
+
/**
|
|
2445
|
+
* Generate embeddings for configured fields
|
|
2446
|
+
*
|
|
2447
|
+
* Creates embedding vectors for fields configured in the @smrt decorator
|
|
2448
|
+
* and stores them in the _smrt_embeddings system table. Uses content hashing
|
|
2449
|
+
* to detect changes and avoid regenerating unchanged content.
|
|
2450
|
+
*
|
|
2451
|
+
* @param options - Generation options
|
|
2452
|
+
* @returns Promise that resolves when embeddings are generated
|
|
2453
|
+
* @throws Error if no embedding configuration or database not initialized
|
|
2454
|
+
*
|
|
2455
|
+
* @example
|
|
2456
|
+
* ```typescript
|
|
2457
|
+
* // Generate embeddings for all configured fields
|
|
2458
|
+
* await article.generateEmbeddings();
|
|
2459
|
+
*
|
|
2460
|
+
* // Generate only for specific fields
|
|
2461
|
+
* await article.generateEmbeddings({ fields: ['title'] });
|
|
2462
|
+
*
|
|
2463
|
+
* // Force regeneration (ignore content hash)
|
|
2464
|
+
* await article.generateEmbeddings({ force: true });
|
|
2465
|
+
* ```
|
|
2466
|
+
*/
|
|
2467
|
+
async generateEmbeddings(options = {}) {
|
|
2468
|
+
if (!this.systemDb) {
|
|
2469
|
+
throw new Error("Database not initialized. Call initialize() first.");
|
|
2470
|
+
}
|
|
2471
|
+
if (!this.id) {
|
|
2472
|
+
throw new Error("Object must have an ID before generating embeddings.");
|
|
2473
|
+
}
|
|
2474
|
+
const config = ObjectRegistry.resolveEmbeddingConfig(this.constructor.name);
|
|
2475
|
+
if (!config) {
|
|
2476
|
+
throw new Error(
|
|
2477
|
+
`No embedding configuration found for ${this.constructor.name}. Add embeddings config to the @smrt() decorator.`
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
const fieldsToProcess = options.fields || config.fields;
|
|
2481
|
+
const provider = options.provider || config.provider;
|
|
2482
|
+
const aiClient = await this.getOptionalAiClient();
|
|
2483
|
+
const embeddingProvider = new EmbeddingProvider(
|
|
2484
|
+
{
|
|
2485
|
+
dimensions: config.dimensions,
|
|
2486
|
+
provider,
|
|
2487
|
+
localModel: config.localModel,
|
|
2488
|
+
aiModel: config.aiModel,
|
|
2489
|
+
fallbackToAI: config.fallbackToAI
|
|
2490
|
+
},
|
|
2491
|
+
aiClient
|
|
2492
|
+
);
|
|
2493
|
+
const projectConfig = ObjectRegistry.getProjectEmbeddingConfig();
|
|
2494
|
+
const vector = projectConfig?.storage === "native" ? this.systemDb.vector : void 0;
|
|
2495
|
+
for (const fieldName of fieldsToProcess) {
|
|
2496
|
+
const content = this.getPropertyValue(fieldName);
|
|
2497
|
+
if (!content || typeof content !== "string") {
|
|
2498
|
+
continue;
|
|
2499
|
+
}
|
|
2500
|
+
const contentHash = ContentHasher.hash(content);
|
|
2501
|
+
if (!options.force) {
|
|
2502
|
+
const existing = await EmbeddingStorage.get(
|
|
2503
|
+
this.systemDb,
|
|
2504
|
+
this.constructor.name,
|
|
2505
|
+
this.id,
|
|
2506
|
+
fieldName,
|
|
2507
|
+
embeddingProvider.getModelName()
|
|
2508
|
+
);
|
|
2509
|
+
if (existing && existing.content_hash === contentHash) {
|
|
2510
|
+
continue;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
const embeddings = await embeddingProvider.embed(content);
|
|
2514
|
+
const embedding = embeddings[0];
|
|
2515
|
+
await EmbeddingStorage.upsert(
|
|
2516
|
+
this.systemDb,
|
|
2517
|
+
{
|
|
2518
|
+
objectClass: this.constructor.name,
|
|
2519
|
+
objectId: this.id,
|
|
2520
|
+
fieldName,
|
|
2521
|
+
contentHash,
|
|
2522
|
+
embedding,
|
|
2523
|
+
model: embeddingProvider.getModelName(),
|
|
2524
|
+
dimensions: config.dimensions,
|
|
2525
|
+
provider
|
|
2526
|
+
},
|
|
2527
|
+
vector
|
|
2528
|
+
);
|
|
2529
|
+
}
|
|
2530
|
+
if (config.combinedField) {
|
|
2531
|
+
const { name, template } = config.combinedField;
|
|
2532
|
+
let combinedContent = template;
|
|
2533
|
+
for (const fieldName of config.fields) {
|
|
2534
|
+
const value = this.getPropertyValue(fieldName) || "";
|
|
2535
|
+
combinedContent = combinedContent.replace(
|
|
2536
|
+
new RegExp(`\\{${fieldName}\\}`, "g"),
|
|
2537
|
+
String(value)
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
if (combinedContent.trim()) {
|
|
2541
|
+
const contentHash = ContentHasher.hash(combinedContent);
|
|
2542
|
+
if (!options.force) {
|
|
2543
|
+
const existing = await EmbeddingStorage.get(
|
|
2544
|
+
this.systemDb,
|
|
2545
|
+
this.constructor.name,
|
|
2546
|
+
this.id,
|
|
2547
|
+
name,
|
|
2548
|
+
embeddingProvider.getModelName()
|
|
2549
|
+
);
|
|
2550
|
+
if (existing && existing.content_hash === contentHash) {
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
const embeddings = await embeddingProvider.embed(combinedContent);
|
|
2555
|
+
const embedding = embeddings[0];
|
|
2556
|
+
await EmbeddingStorage.upsert(
|
|
2557
|
+
this.systemDb,
|
|
2558
|
+
{
|
|
2559
|
+
objectClass: this.constructor.name,
|
|
2560
|
+
objectId: this.id,
|
|
2561
|
+
fieldName: name,
|
|
2562
|
+
contentHash,
|
|
2563
|
+
embedding,
|
|
2564
|
+
model: embeddingProvider.getModelName(),
|
|
2565
|
+
dimensions: config.dimensions,
|
|
2566
|
+
provider
|
|
2567
|
+
},
|
|
2568
|
+
vector
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* Get stored embedding for a field
|
|
2575
|
+
*
|
|
2576
|
+
* Retrieves the embedding vector for a specific field from storage.
|
|
2577
|
+
* Returns null if no embedding exists for the field.
|
|
2578
|
+
*
|
|
2579
|
+
* @param fieldName - Name of the field to get embedding for
|
|
2580
|
+
* @param model - Optional model name (defaults to configured model)
|
|
2581
|
+
* @returns Promise resolving to embedding vector or null
|
|
2582
|
+
*
|
|
2583
|
+
* @example
|
|
2584
|
+
* ```typescript
|
|
2585
|
+
* const embedding = await article.getEmbedding('title');
|
|
2586
|
+
* if (embedding) {
|
|
2587
|
+
* console.log(`Embedding has ${embedding.length} dimensions`);
|
|
2588
|
+
* }
|
|
2589
|
+
* ```
|
|
2590
|
+
*/
|
|
2591
|
+
async getEmbedding(fieldName, model) {
|
|
2592
|
+
if (!this.systemDb) {
|
|
2593
|
+
throw new Error("Database not initialized. Call initialize() first.");
|
|
2594
|
+
}
|
|
2595
|
+
if (!this.id) {
|
|
2596
|
+
return null;
|
|
2597
|
+
}
|
|
2598
|
+
let modelName = model;
|
|
2599
|
+
if (!modelName) {
|
|
2600
|
+
const config = ObjectRegistry.resolveEmbeddingConfig(
|
|
2601
|
+
this.constructor.name
|
|
2602
|
+
);
|
|
2603
|
+
if (config) {
|
|
2604
|
+
const aiClient = await this.getOptionalAiClient();
|
|
2605
|
+
const provider = new EmbeddingProvider(
|
|
2606
|
+
{
|
|
2607
|
+
dimensions: config.dimensions,
|
|
2608
|
+
provider: config.provider,
|
|
2609
|
+
localModel: config.localModel,
|
|
2610
|
+
aiModel: config.aiModel,
|
|
2611
|
+
fallbackToAI: config.fallbackToAI
|
|
2612
|
+
},
|
|
2613
|
+
aiClient
|
|
2614
|
+
);
|
|
2615
|
+
modelName = provider.getModelName();
|
|
2616
|
+
} else {
|
|
2617
|
+
modelName = "Xenova/bge-base-en-v1.5";
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
const stored = await EmbeddingStorage.get(
|
|
2621
|
+
this.systemDb,
|
|
2622
|
+
this.constructor.name,
|
|
2623
|
+
this.id,
|
|
2624
|
+
fieldName,
|
|
2625
|
+
modelName
|
|
2626
|
+
);
|
|
2627
|
+
return stored?.embedding || null;
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* Check if any embeddings are stale (content has changed)
|
|
2631
|
+
*
|
|
2632
|
+
* Compares content hashes of configured fields with stored embeddings
|
|
2633
|
+
* to determine if regeneration is needed.
|
|
2634
|
+
*
|
|
2635
|
+
* @returns Promise resolving to true if any embeddings are stale
|
|
2636
|
+
*
|
|
2637
|
+
* @example
|
|
2638
|
+
* ```typescript
|
|
2639
|
+
* if (await article.hasStaleEmbeddings()) {
|
|
2640
|
+
* await article.generateEmbeddings();
|
|
2641
|
+
* }
|
|
2642
|
+
* ```
|
|
2643
|
+
*/
|
|
2644
|
+
async hasStaleEmbeddings() {
|
|
2645
|
+
if (!this.systemDb || !this.id) {
|
|
2646
|
+
return false;
|
|
2647
|
+
}
|
|
2648
|
+
const config = ObjectRegistry.resolveEmbeddingConfig(this.constructor.name);
|
|
2649
|
+
if (!config) {
|
|
2650
|
+
return false;
|
|
2651
|
+
}
|
|
2652
|
+
const aiClient = await this.getOptionalAiClient();
|
|
2653
|
+
const embeddingProvider = new EmbeddingProvider(
|
|
2654
|
+
{
|
|
2655
|
+
dimensions: config.dimensions,
|
|
2656
|
+
provider: config.provider,
|
|
2657
|
+
localModel: config.localModel,
|
|
2658
|
+
aiModel: config.aiModel,
|
|
2659
|
+
fallbackToAI: config.fallbackToAI
|
|
2660
|
+
},
|
|
2661
|
+
aiClient
|
|
2662
|
+
);
|
|
2663
|
+
const modelName = embeddingProvider.getModelName();
|
|
2664
|
+
const storedEmbeddings = await EmbeddingStorage.getForObject(
|
|
2665
|
+
this.systemDb,
|
|
2666
|
+
this.constructor.name,
|
|
2667
|
+
this.id
|
|
2668
|
+
);
|
|
2669
|
+
for (const fieldName of config.fields) {
|
|
2670
|
+
const content = this.getPropertyValue(fieldName);
|
|
2671
|
+
if (!content || typeof content !== "string") {
|
|
2672
|
+
continue;
|
|
2673
|
+
}
|
|
2674
|
+
const currentHash = ContentHasher.hash(content);
|
|
2675
|
+
const stored = storedEmbeddings.find(
|
|
2676
|
+
(e) => e.field_name === fieldName && e.model === modelName
|
|
2677
|
+
);
|
|
2678
|
+
if (!stored) {
|
|
2679
|
+
return true;
|
|
2680
|
+
}
|
|
2681
|
+
if (stored.content_hash !== currentHash) {
|
|
2682
|
+
return true;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
if (config.combinedField) {
|
|
2686
|
+
let combinedContent = config.combinedField.template;
|
|
2687
|
+
for (const fieldName of config.fields) {
|
|
2688
|
+
const value = this.getPropertyValue(fieldName) || "";
|
|
2689
|
+
combinedContent = combinedContent.replace(
|
|
2690
|
+
new RegExp(`\\{${fieldName}\\}`, "g"),
|
|
2691
|
+
String(value)
|
|
2692
|
+
);
|
|
2693
|
+
}
|
|
2694
|
+
const currentHash = ContentHasher.hash(combinedContent);
|
|
2695
|
+
const stored = storedEmbeddings.find(
|
|
2696
|
+
(e) => e.field_name === config.combinedField?.name && e.model === modelName
|
|
2697
|
+
);
|
|
2698
|
+
if (!stored || stored.content_hash !== currentHash) {
|
|
2699
|
+
return true;
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
return false;
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Clear all embeddings for this object
|
|
2706
|
+
*
|
|
2707
|
+
* Removes all stored embeddings from the _smrt_embeddings table.
|
|
2708
|
+
* Useful when deleting objects or resetting embedding state.
|
|
2709
|
+
*
|
|
2710
|
+
* @returns Promise that resolves when embeddings are cleared
|
|
2711
|
+
*
|
|
2712
|
+
* @example
|
|
2713
|
+
* ```typescript
|
|
2714
|
+
* await article.clearEmbeddings();
|
|
2715
|
+
* ```
|
|
2716
|
+
*/
|
|
2717
|
+
async clearEmbeddings() {
|
|
2718
|
+
if (!this.systemDb || !this.id) {
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
await EmbeddingStorage.deleteForObject(
|
|
2722
|
+
this.systemDb,
|
|
2723
|
+
this.constructor.name,
|
|
2724
|
+
this.id
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
export {
|
|
2729
|
+
SmrtObject
|
|
2730
|
+
};
|
|
2731
|
+
//# sourceMappingURL=object.js.map
|