@happyvertical/smrt-core 0.40.69 → 0.41.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 +29 -4
- package/README.md +20 -1
- package/agents/change-feed.md +1 -1
- package/agents/query-bounds.md +45 -0
- package/agents/schema-paths.md +786 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +5 -3
- package/dist/cascade.d.ts +120 -0
- package/dist/cascade.d.ts.map +1 -0
- package/dist/cascade.js +430 -0
- package/dist/cascade.js.map +1 -0
- package/dist/change-feed.d.ts +34 -2
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +52 -11
- package/dist/change-feed.js.map +1 -1
- package/dist/class.d.ts +36 -3
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +87 -9
- package/dist/class.js.map +1 -1
- package/dist/collection-cache.js +0 -0
- package/dist/collection-cache.js.map +1 -1
- package/dist/collection.d.ts +130 -2
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +290 -57
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/database.d.ts +8 -0
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +16 -8
- package/dist/database.js.map +1 -1
- package/dist/db-errors.d.ts +105 -0
- package/dist/db-errors.d.ts.map +1 -0
- package/dist/db-errors.js +382 -0
- package/dist/db-errors.js.map +1 -0
- package/dist/decorators/index.d.ts +80 -6
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +102 -12
- package/dist/decorators/index.js.map +1 -1
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +4 -3
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
- package/dist/dispatch/collections/Dispatches.js +19 -4
- package/dist/dispatch/collections/Dispatches.js.map +1 -1
- package/dist/dispatch/types.d.ts +5 -0
- package/dist/dispatch/types.d.ts.map +1 -1
- package/dist/embedded-write-queue.d.ts +46 -0
- package/dist/embedded-write-queue.d.ts.map +1 -0
- package/dist/embedded-write-queue.js +66 -0
- package/dist/embedded-write-queue.js.map +1 -0
- package/dist/embeddings/storage.d.ts +7 -0
- package/dist/embeddings/storage.d.ts.map +1 -1
- package/dist/embeddings/storage.js +29 -12
- package/dist/embeddings/storage.js.map +1 -1
- package/dist/errors.d.ts +31 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +34 -2
- package/dist/errors.js.map +1 -1
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +6 -3
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/mcp-runtime-template.d.ts +8 -0
- package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
- package/dist/generators/mcp-runtime-template.js +38 -4
- package/dist/generators/mcp-runtime-template.js.map +1 -1
- package/dist/generators/mcp.d.ts +16 -0
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +41 -3
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/rest.d.ts +22 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +34 -3
- package/dist/generators/rest.js.map +1 -1
- package/dist/hierarchical.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/interceptors.d.ts +21 -0
- package/dist/interceptors.d.ts.map +1 -1
- package/dist/interceptors.js +27 -1
- package/dist/interceptors.js.map +1 -1
- package/dist/manifest/generator.d.ts.map +1 -1
- package/dist/manifest/generator.js +4 -7
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/static-manifest.js +10 -10
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +19 -19
- package/dist/migrations/differ.d.ts +211 -9
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +613 -50
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/generator.d.ts +31 -4
- package/dist/migrations/generator.d.ts.map +1 -1
- package/dist/migrations/generator.js +49 -5
- package/dist/migrations/generator.js.map +1 -1
- package/dist/migrations/index.d.ts +4 -2
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +6 -3
- package/dist/migrations/minor-units.d.ts +162 -0
- package/dist/migrations/minor-units.d.ts.map +1 -0
- package/dist/migrations/minor-units.js +381 -0
- package/dist/migrations/minor-units.js.map +1 -0
- package/dist/migrations/orchestrate.js +35 -6
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/sqlite-rebuild.d.ts +142 -0
- package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
- package/dist/migrations/sqlite-rebuild.js +514 -0
- package/dist/migrations/sqlite-rebuild.js.map +1 -0
- package/dist/migrations/tracker.d.ts +114 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +331 -16
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations/types.d.ts +19 -4
- package/dist/migrations/types.d.ts.map +1 -1
- package/dist/migrations.js +6 -3
- package/dist/object.d.ts +142 -10
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +196 -41
- package/dist/object.js.map +1 -1
- package/dist/postgres-timeouts.d.ts +240 -0
- package/dist/postgres-timeouts.d.ts.map +1 -0
- package/dist/postgres-timeouts.js +204 -0
- package/dist/postgres-timeouts.js.map +1 -0
- package/dist/query-bounds.d.ts +101 -0
- package/dist/query-bounds.d.ts.map +1 -0
- package/dist/query-bounds.js +177 -0
- package/dist/query-bounds.js.map +1 -0
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +3 -1
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/manifest-field-merge.d.ts +12 -0
- package/dist/registry/manifest-field-merge.d.ts.map +1 -1
- package/dist/registry/manifest-field-merge.js +14 -2
- package/dist/registry/manifest-field-merge.js.map +1 -1
- package/dist/registry/schema-builder.d.ts +22 -1
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +205 -165
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +35 -3
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +41 -46
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +61 -83
- package/dist/registry.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts +45 -0
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +92 -28
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +5 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/conflict-target.d.ts +104 -0
- package/dist/schema/conflict-target.d.ts.map +1 -0
- package/dist/schema/conflict-target.js +129 -0
- package/dist/schema/conflict-target.js.map +1 -0
- package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/base-strategy.js +2 -2
- package/dist/schema/ddl/base-strategy.js.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.js +2 -1
- package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
- package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/postgres-strategy.js +12 -1
- package/dist/schema/ddl/postgres-strategy.js.map +1 -1
- package/dist/schema/generator.d.ts +307 -6
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +510 -87
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/index-utils.d.ts +120 -0
- package/dist/schema/index-utils.d.ts.map +1 -1
- package/dist/schema/index-utils.js +242 -1
- package/dist/schema/index-utils.js.map +1 -1
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/live-parity.d.ts +90 -0
- package/dist/schema/live-parity.d.ts.map +1 -0
- package/dist/schema/live-parity.js +602 -0
- package/dist/schema/live-parity.js.map +1 -0
- package/dist/schema/manifest-schema.d.ts +121 -0
- package/dist/schema/manifest-schema.d.ts.map +1 -0
- package/dist/schema/manifest-schema.js +267 -0
- package/dist/schema/manifest-schema.js.map +1 -0
- package/dist/schema/schema-aggregator.d.ts +24 -10
- package/dist/schema/schema-aggregator.d.ts.map +1 -1
- package/dist/schema/schema-aggregator.js +35 -90
- package/dist/schema/schema-aggregator.js.map +1 -1
- package/dist/schema/system-table-shapes.d.ts +65 -0
- package/dist/schema/system-table-shapes.d.ts.map +1 -0
- package/dist/schema/system-table-shapes.js +187 -0
- package/dist/schema/system-table-shapes.js.map +1 -0
- package/dist/schema/types.d.ts +103 -4
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/utils.d.ts +2 -1
- package/dist/schema/utils.d.ts.map +1 -1
- package/dist/schema/utils.js +5 -3
- package/dist/schema/utils.js.map +1 -1
- package/dist/schema.js +4 -1
- package/dist/smrt-knowledge.json +20 -8
- package/dist/sync/apply.d.ts.map +1 -1
- package/dist/sync/apply.js +9 -16
- package/dist/sync/apply.js.map +1 -1
- package/dist/system/compatibility.d.ts +42 -0
- package/dist/system/compatibility.d.ts.map +1 -1
- package/dist/system/compatibility.js +182 -9
- package/dist/system/compatibility.js.map +1 -1
- package/dist/system/index.d.ts +1 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +3 -2
- package/dist/system/retention.d.ts +237 -0
- package/dist/system/retention.d.ts.map +1 -0
- package/dist/system/retention.js +497 -0
- package/dist/system/retention.js.map +1 -0
- package/dist/system/schema.d.ts +100 -15
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +81 -45
- package/dist/system/schema.js.map +1 -1
- package/dist/system/types.d.ts +0 -2
- package/dist/system/types.d.ts.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +1 -0
- package/dist/testing/database.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +4 -9
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +71 -5
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +6 -4
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +5 -5
package/dist/object.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { classifyDatabaseError, classifyDialectMessage } from "./db-errors.js";
|
|
2
|
+
import { DatabaseError, ErrorUtils, RuntimeError, SmrtError, TenantIsolationError, ValidationError } from "./errors.js";
|
|
3
|
+
import { toSnakeCase } from "./utils/naming.js";
|
|
4
|
+
import { runCascadeDelete } from "./cascade.js";
|
|
1
5
|
import { broadcastCacheInvalidation, hasCrossProcessCacheInterest, invalidateCollectionCache, resolveDbCacheKey } from "./collection-cache.js";
|
|
2
|
-
import {
|
|
6
|
+
import { isEmbeddedDatabase, withEmbeddedWriteQueue } from "./embedded-write-queue.js";
|
|
7
|
+
import { GlobalInterceptors, createInterceptorContext, resolveGetStringFilter } from "./interceptors.js";
|
|
3
8
|
import { SmrtClass } from "./class.js";
|
|
4
9
|
import { EmbeddingProvider } from "./embeddings/provider.js";
|
|
5
10
|
import { EmbeddingStorage } from "./embeddings/storage.js";
|
|
6
|
-
import { DatabaseError, ErrorUtils, RuntimeError, SmrtError, TenantIsolationError, ValidationError } from "./errors.js";
|
|
7
|
-
import { toSnakeCase } from "./utils/naming.js";
|
|
8
11
|
import { ContentHasher } from "./embeddings/hash.js";
|
|
9
12
|
import { verifyPersistenceTable } from "./schema/table-verifier.js";
|
|
10
13
|
import { executeToolCall } from "./tools/tool-executor.js";
|
|
11
|
-
import { fieldsFromClass, tableNameFromClass } from "./utils.js";
|
|
14
|
+
import { fieldsFromClass, keysToSnakeCase, tableNameFromClass } from "./utils.js";
|
|
12
15
|
import { ObjectRegistry } from "./registry.js";
|
|
13
16
|
import { createLogger } from "@happyvertical/logger";
|
|
14
17
|
//#region src/object.ts
|
|
@@ -658,9 +661,9 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
658
661
|
const value = this.getPropertyValue(key);
|
|
659
662
|
if (value === void 0) {
|
|
660
663
|
const fieldType = prop && typeof prop === "object" && "type" in prop && prop.type || fieldDef?.type;
|
|
661
|
-
if (
|
|
664
|
+
if ((prop && typeof prop === "object" && "__tenancy" in prop ? prop.__tenancy : void 0)?.isTenantIdField || fieldDef?.__tenancy?.isTenantIdField || fieldDef?._meta?.__tenancy?.isTenantIdField) if (isSTI && fieldDef?.type === "meta") metaData[key] = null;
|
|
662
665
|
else data[key] = null;
|
|
663
|
-
else if (isSTI && fieldDef?.type === "meta") metaData[key] = "";
|
|
666
|
+
else if (fieldType === "text") if (isSTI && fieldDef?.type === "meta") metaData[key] = "";
|
|
664
667
|
else data[key] = "";
|
|
665
668
|
else if (fieldType === "json") {
|
|
666
669
|
const defaultValue = fieldDef?.default ?? null;
|
|
@@ -800,15 +803,21 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
800
803
|
/**
|
|
801
804
|
* Gets or generates a unique ID for this object
|
|
802
805
|
*
|
|
806
|
+
* The natural-key lookup runs through the `beforeGet` interceptor pipeline
|
|
807
|
+
* (#2365), so under an active tenant context this can only adopt the id of
|
|
808
|
+
* a row visible to that tenant — never another tenant's same-slug row,
|
|
809
|
+
* which would both disclose the foreign id and steer a subsequent `save()`
|
|
810
|
+
* onto the foreign row.
|
|
811
|
+
*
|
|
803
812
|
* @returns Promise resolving to the object's ID
|
|
804
813
|
*/
|
|
805
814
|
async getId() {
|
|
806
815
|
if (this.slug) {
|
|
807
816
|
await this.verifyStorageReady();
|
|
808
|
-
const saved = await this.db.get(this.tableName, {
|
|
817
|
+
const saved = await this.db.get(this.tableName, await this.interceptGetFilter({
|
|
809
818
|
slug: this.slug,
|
|
810
819
|
context: this.context
|
|
811
|
-
});
|
|
820
|
+
}));
|
|
812
821
|
if (saved) this.id = saved.id;
|
|
813
822
|
}
|
|
814
823
|
if (!this.id) this.id = crypto.randomUUID();
|
|
@@ -849,22 +858,56 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
849
858
|
/**
|
|
850
859
|
* Gets the ID of this object if it's already saved in the database
|
|
851
860
|
*
|
|
861
|
+
* Both lookups run through the `beforeGet` interceptor pipeline (#2365), so
|
|
862
|
+
* under an active tenant context only rows visible to that tenant count as
|
|
863
|
+
* "saved".
|
|
864
|
+
*
|
|
852
865
|
* @returns Promise resolving to the saved ID or null if not saved
|
|
853
866
|
*/
|
|
854
867
|
async getSavedId() {
|
|
855
868
|
if (!this.id && !this.slug) return null;
|
|
856
869
|
await this.verifyStorageReady();
|
|
857
870
|
if (this.id) {
|
|
858
|
-
const byId = await this.db.get(this.tableName, { id: this.id });
|
|
871
|
+
const byId = await this.db.get(this.tableName, await this.interceptGetFilter({ id: this.id }));
|
|
859
872
|
if (byId) return byId.id;
|
|
860
873
|
}
|
|
861
874
|
if (this.slug) {
|
|
862
|
-
const bySlug = await this.db.get(this.tableName, { slug: this.slug });
|
|
875
|
+
const bySlug = await this.db.get(this.tableName, await this.interceptGetFilter({ slug: this.slug }));
|
|
863
876
|
if (bySlug) return bySlug.id;
|
|
864
877
|
}
|
|
865
878
|
return null;
|
|
866
879
|
}
|
|
867
880
|
/**
|
|
881
|
+
* Run the registered `beforeGet` interceptors over a direct hydration filter
|
|
882
|
+
* and return it in database column form (#2365).
|
|
883
|
+
*
|
|
884
|
+
* `loadFromId()`, `loadFromSlug()`, `getSavedId()` and `getId()` query
|
|
885
|
+
* `this.db.get` directly instead of going through `SmrtCollection.get()`, so
|
|
886
|
+
* before this hook they bypassed every read interceptor: under an active tenant context,
|
|
887
|
+
* `new Model({ slug }).initialize()` happily hydrated another tenant's row
|
|
888
|
+
* even though every collection read was filtered. Routing the filter through
|
|
889
|
+
* `GlobalInterceptors.executeBeforeGet` gives hydration the same read
|
|
890
|
+
* predicate as collection reads — the tenancy interceptor adds its tenant
|
|
891
|
+
* filter (or throws `TenantContextError` for `mode: 'required'` classes
|
|
892
|
+
* outside a tenant context), and the existing bypasses (`withSystemContext`,
|
|
893
|
+
* super-admin bypass) keep working because they short-circuit inside the
|
|
894
|
+
* interceptor itself.
|
|
895
|
+
*
|
|
896
|
+
* Interceptors speak field names (`tenantId`); `this.db.get` speaks column
|
|
897
|
+
* names, so the merged filter is converted to snake_case before use. The
|
|
898
|
+
* base filters (`id`, `slug`, `context`) are already column-shaped and pass
|
|
899
|
+
* through unchanged.
|
|
900
|
+
*
|
|
901
|
+
* @param filter - The column-shaped hydration filter to intercept
|
|
902
|
+
* @returns The intercepted filter with all keys in column form
|
|
903
|
+
*/
|
|
904
|
+
async interceptGetFilter(filter) {
|
|
905
|
+
const className = this.getResolvedClassName();
|
|
906
|
+
const interceptorContext = createInterceptorContext(className, "get");
|
|
907
|
+
const intercepted = await GlobalInterceptors.executeBeforeGet(className, filter, interceptorContext);
|
|
908
|
+
return keysToSnakeCase(typeof intercepted === "string" ? resolveGetStringFilter(intercepted) : intercepted);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
868
911
|
* Checks if this object is already saved in the database
|
|
869
912
|
*
|
|
870
913
|
* @returns Promise resolving to true if saved, false otherwise
|
|
@@ -948,16 +991,34 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
948
991
|
* 3. Assigns a UUID `id` if not already set
|
|
949
992
|
* 4. Generates a `slug` via `getSlug()` if not already set
|
|
950
993
|
* 5. Updates `updated_at` (and sets `created_at` on first save)
|
|
951
|
-
* 6. Upserts the row
|
|
994
|
+
* 6. Upserts the row, retrying only failures a later attempt could survive
|
|
952
995
|
* 7. Executes `afterSave` interceptors
|
|
953
996
|
* 8. Triggers embedding generation in the background if configured
|
|
954
997
|
*
|
|
955
998
|
* For STI classes, validates that `_meta_type` is present and correct
|
|
956
999
|
* before writing to the database.
|
|
957
1000
|
*
|
|
1001
|
+
* **Error contract (#2366).** The driver error is classified through its
|
|
1002
|
+
* whole cause chain, because `@happyvertical/sql` wraps it as
|
|
1003
|
+
* `DatabaseError('Failed to upsert record into table', …)` and the
|
|
1004
|
+
* constraint wording never reaches the outer `message`. A deterministic
|
|
1005
|
+
* failure — unique/not-null/foreign-key/check violation, invalid input
|
|
1006
|
+
* syntax, missing table, or a statement issued inside an already-aborted
|
|
1007
|
+
* PostgreSQL transaction (`25P02`) — is raised on the **first** attempt with
|
|
1008
|
+
* no backoff, so the original cause survives instead of being replaced by
|
|
1009
|
+
* `25P02` on a doomed retry inside a caller-managed transaction. Only
|
|
1010
|
+
* transient failures (serialization failure, deadlock, lock timeout, dropped
|
|
1011
|
+
* connection, `SQLITE_BUSY`) are retried: up to 4 attempts total — the
|
|
1012
|
+
* initial try plus 3 retries — sleeping 500, 1000 and 2000 ms between them.
|
|
1013
|
+
*
|
|
958
1014
|
* @returns This instance after saving (enables chaining)
|
|
959
|
-
* @throws {ValidationError}
|
|
960
|
-
*
|
|
1015
|
+
* @throws {ValidationError} `VALIDATION_UNIQUE_CONSTRAINT` on a unique or
|
|
1016
|
+
* primary-key violation, `VALIDATION_REQUIRED_FIELD` on a NOT NULL
|
|
1017
|
+
* violation — on every adapter, and never retried
|
|
1018
|
+
* @throws {DatabaseError} If the table does not exist (`DB_SCHEMA_MISSING`),
|
|
1019
|
+
* or the query fails for any other reason (`DB_QUERY_FAILED`), including
|
|
1020
|
+
* foreign-key and check violations, whose original driver error is
|
|
1021
|
+
* preserved on `cause`
|
|
961
1022
|
* @throws {RuntimeError} For any other unexpected failure during save
|
|
962
1023
|
*
|
|
963
1024
|
* @example
|
|
@@ -1006,7 +1067,8 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1006
1067
|
const conflictColumns = ObjectRegistry.getConflictColumns(className);
|
|
1007
1068
|
const writePlan = await this.planPersistenceWrite(className, tableStrategy, data, conflictColumns);
|
|
1008
1069
|
const upsertConflictColumns = this._persisted && data.id ? ["id"] : conflictColumns;
|
|
1009
|
-
|
|
1070
|
+
const serializeEmbeddedWrite = writePlan.type !== "updateById" && !(this._insertOnly && !this._persisted) && isEmbeddedDatabase(this.db) && upsertConflictColumns.some((column) => data[column] == null);
|
|
1071
|
+
await withEmbeddedWriteQueue(this.db, serializeEmbeddedWrite, () => ErrorUtils.withRetry(async () => {
|
|
1010
1072
|
try {
|
|
1011
1073
|
if (writePlan.type === "updateById") {
|
|
1012
1074
|
const { id: _id, ...updateData } = data;
|
|
@@ -1016,13 +1078,13 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1016
1078
|
else await this.db.upsert(this.tableName, upsertConflictColumns, data);
|
|
1017
1079
|
} catch (error) {
|
|
1018
1080
|
if (error instanceof Error) {
|
|
1019
|
-
const
|
|
1020
|
-
if (kind === "
|
|
1021
|
-
const field = this.
|
|
1081
|
+
const classification = classifyDatabaseError(error);
|
|
1082
|
+
if (classification.kind === "unique_violation") {
|
|
1083
|
+
const field = this.extractConstraintFieldFromChain(error, classification);
|
|
1022
1084
|
throw ValidationError.uniqueConstraint(field, this.getFieldValue(field));
|
|
1023
1085
|
}
|
|
1024
|
-
if (kind === "
|
|
1025
|
-
const field = this.
|
|
1086
|
+
if (classification.kind === "not_null_violation") {
|
|
1087
|
+
const field = this.extractConstraintFieldFromChain(error, classification);
|
|
1026
1088
|
throw ValidationError.requiredField(field, className);
|
|
1027
1089
|
}
|
|
1028
1090
|
const operation = writePlan.type === "updateById" ? `UPDATE ${this.tableName} (id-targeted)` : this._insertOnly && !this._persisted ? `INSERT INTO ${this.tableName}` : `UPSERT INTO ${this.tableName}`;
|
|
@@ -1030,7 +1092,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1030
1092
|
}
|
|
1031
1093
|
throw error;
|
|
1032
1094
|
}
|
|
1033
|
-
}, 3, 500);
|
|
1095
|
+
}, 3, 500));
|
|
1034
1096
|
this._persisted = true;
|
|
1035
1097
|
this.invalidateCollectionReadCache();
|
|
1036
1098
|
await GlobalInterceptors.executeAfterSave(this, interceptorContext);
|
|
@@ -1178,11 +1240,34 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1178
1240
|
*/
|
|
1179
1241
|
static classifyConstraintError(message) {
|
|
1180
1242
|
if (!message) return null;
|
|
1181
|
-
|
|
1182
|
-
if (
|
|
1243
|
+
const kind = classifyDialectMessage(message);
|
|
1244
|
+
if (kind === "not_null_violation") return "not_null";
|
|
1245
|
+
if (kind === "unique_violation") return "unique";
|
|
1183
1246
|
return null;
|
|
1184
1247
|
}
|
|
1185
1248
|
/**
|
|
1249
|
+
* Recovers the offending column for a constraint violation, searching every
|
|
1250
|
+
* message in the driver-error cause chain rather than only the outermost
|
|
1251
|
+
* one (#2366).
|
|
1252
|
+
*
|
|
1253
|
+
* Prefers the column the driver named outright (`node-postgres` sets
|
|
1254
|
+
* `error.column` on a `23502`), then falls back to parsing. The adapter's
|
|
1255
|
+
* wrapper message ("Failed to upsert record into table") carries no column,
|
|
1256
|
+
* and PostgreSQL puts a unique violation's column in the `DETAIL` line
|
|
1257
|
+
* ("Key (sku)=(abc) already exists.") — reachable either as `error.detail`
|
|
1258
|
+
* on the direct INSERT path or folded into `formatDbError`'s `detail=…`
|
|
1259
|
+
* string on the upsert path. Returns `'unknown_field'` only when nothing in
|
|
1260
|
+
* the chain names a column.
|
|
1261
|
+
*/
|
|
1262
|
+
extractConstraintFieldFromChain(error, classification) {
|
|
1263
|
+
if (classification.column) return classification.column;
|
|
1264
|
+
for (const message of [error.message, ...classification.driverMessages]) {
|
|
1265
|
+
const field = this.extractConstraintField(message);
|
|
1266
|
+
if (field !== "unknown_field") return field;
|
|
1267
|
+
}
|
|
1268
|
+
return "unknown_field";
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1186
1271
|
* Extracts field name from database constraint error messages.
|
|
1187
1272
|
*
|
|
1188
1273
|
* Handles SQLite, PostgreSQL and DuckDB phrasings. Returns
|
|
@@ -1204,16 +1289,23 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1204
1289
|
/**
|
|
1205
1290
|
* Hydrates this object from the database using its `id` property.
|
|
1206
1291
|
*
|
|
1207
|
-
* Queries the database for a row matching `{ id: this._id }`
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1292
|
+
* Queries the database for a row matching `{ id: this._id }` — after running
|
|
1293
|
+
* the filter through the `beforeGet` interceptor pipeline (#2365), so tenant
|
|
1294
|
+
* scoping applies to hydration exactly as it does to collection reads — and
|
|
1295
|
+
* calls `loadDataFromDb()` if found. Transient failures are retried up to 4 times
|
|
1296
|
+
* total — the initial try plus 3 retries — sleeping 250, 500 and 1000 ms
|
|
1297
|
+
* between them; deterministic failures are not. A malformed identifier
|
|
1298
|
+
* (PostgreSQL `22P02 invalid_text_representation` on a `uuid` column) is a
|
|
1299
|
+
* deterministic failure, so it now raises on the first attempt instead of
|
|
1300
|
+
* spending three retries re-submitting the same bad literal (#2366).
|
|
1210
1301
|
*
|
|
1211
1302
|
* Called automatically by `initialize()` when `options.id` is provided.
|
|
1212
1303
|
* Typically you do not need to call this directly.
|
|
1213
1304
|
*
|
|
1214
1305
|
* @returns Promise that resolves when loading is complete (no-op if not found)
|
|
1215
1306
|
* @throws {ValidationError} If `this._id` is not set
|
|
1216
|
-
* @throws {DatabaseError} If the query fails
|
|
1307
|
+
* @throws {DatabaseError} If the query fails; the driver error is preserved
|
|
1308
|
+
* on `cause`
|
|
1217
1309
|
*
|
|
1218
1310
|
* @example
|
|
1219
1311
|
* ```typescript
|
|
@@ -1224,12 +1316,13 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1224
1316
|
* ```
|
|
1225
1317
|
*/
|
|
1226
1318
|
async loadFromId() {
|
|
1319
|
+
if (!this._id) throw ValidationError.requiredField("id", this.constructor.name);
|
|
1320
|
+
const filter = await this.interceptGetFilter({ id: this._id });
|
|
1227
1321
|
try {
|
|
1228
|
-
if (!this._id) throw ValidationError.requiredField("id", this.constructor.name);
|
|
1229
1322
|
await this.verifyStorageReady();
|
|
1230
1323
|
await ErrorUtils.withRetry(async () => {
|
|
1231
1324
|
try {
|
|
1232
|
-
const existing = await this.db.get(this.tableName,
|
|
1325
|
+
const existing = await this.db.get(this.tableName, filter);
|
|
1233
1326
|
if (existing) await this.loadDataFromDb(existing);
|
|
1234
1327
|
} catch (error) {
|
|
1235
1328
|
throw DatabaseError.queryFailed(`get(${this.tableName}, { id: ${this._id} })`, error instanceof Error ? error : new Error(String(error)));
|
|
@@ -1261,10 +1354,10 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1261
1354
|
*/
|
|
1262
1355
|
async loadFromSlug() {
|
|
1263
1356
|
await this.verifyStorageReady();
|
|
1264
|
-
const existing = await this.db.get(this.tableName, {
|
|
1357
|
+
const existing = await this.db.get(this.tableName, await this.interceptGetFilter({
|
|
1265
1358
|
slug: this._slug,
|
|
1266
1359
|
context: this._context || ""
|
|
1267
|
-
});
|
|
1360
|
+
}));
|
|
1268
1361
|
if (existing) await this.loadDataFromDb(existing);
|
|
1269
1362
|
}
|
|
1270
1363
|
/**
|
|
@@ -1463,34 +1556,78 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1463
1556
|
} else if (typeof hook === "function") await hook(this);
|
|
1464
1557
|
}
|
|
1465
1558
|
/**
|
|
1466
|
-
* Deletes this object from the database
|
|
1559
|
+
* Deletes this object from the database, cascading to the rows that
|
|
1560
|
+
* reference it.
|
|
1467
1561
|
*
|
|
1468
1562
|
* Runs the full lifecycle in order:
|
|
1469
1563
|
* 1. `beforeDelete` interceptors (e.g. tenant validation)
|
|
1470
1564
|
* 2. `beforeDelete` lifecycle hook (defined in `@smrt({ hooks })`)
|
|
1471
|
-
* 3.
|
|
1565
|
+
* 3. Referential cleanup and the row deletion (one transaction when there
|
|
1566
|
+
* is anything to clean up — see below)
|
|
1472
1567
|
* 4. `afterDelete` lifecycle hook
|
|
1473
1568
|
* 5. `afterDelete` interceptors
|
|
1474
1569
|
*
|
|
1570
|
+
* ## Referential cleanup (#2371)
|
|
1571
|
+
*
|
|
1572
|
+
* SMRT emits no DB-level `FOREIGN KEY` constraints, so step 3 enforces
|
|
1573
|
+
* referential integrity in the application layer instead:
|
|
1574
|
+
*
|
|
1575
|
+
* - `_smrt_embeddings` and `_smrt_contexts` rows owned by this object are
|
|
1576
|
+
* removed, so a deleted object can no longer win a `semanticSearch` slot
|
|
1577
|
+
* or resurrect stale `recall()` values.
|
|
1578
|
+
* - Polymorphic association rows whose `(metaType, metaId)` points here are
|
|
1579
|
+
* removed.
|
|
1580
|
+
* - `@foreignKey` / `@crossPackageRef` columns pointing at this class are
|
|
1581
|
+
* resolved according to their declared `onDelete` — `'CASCADE'`,
|
|
1582
|
+
* `'SET NULL'` or `'RESTRICT'`. Without a declaration, a column that is
|
|
1583
|
+
* part of the referencing class's `conflictColumns` (junction and
|
|
1584
|
+
* association rows) defaults to `CASCADE`; every other column is left
|
|
1585
|
+
* untouched, as before.
|
|
1586
|
+
*
|
|
1587
|
+
* Cascaded rows are removed with set-based statements: their own hooks and
|
|
1588
|
+
* interceptors do **not** run and no change-feed tombstone is written for
|
|
1589
|
+
* them, exactly as a DB-level `ON DELETE CASCADE` behaves. Only this object
|
|
1590
|
+
* runs the lifecycle above.
|
|
1591
|
+
*
|
|
1592
|
+
* When anything references this class, step 3 runs inside a single
|
|
1593
|
+
* transaction when the adapter supports one, so a failed cascade cannot
|
|
1594
|
+
* leave the object deleted with its junction rows intact. When nothing
|
|
1595
|
+
* references it, there is nothing to keep consistent and the transaction
|
|
1596
|
+
* is skipped so an ordinary delete costs no extra round trip — but that
|
|
1597
|
+
* skip requires no registered polymorphic association class anywhere in
|
|
1598
|
+
* the process, not just no typed reference to this class (see
|
|
1599
|
+
* `cascade.ts`'s module doc: a `metaType` column can point at any class at
|
|
1600
|
+
* runtime, so a polymorphic association class is always plausibly
|
|
1601
|
+
* relevant).
|
|
1602
|
+
*
|
|
1475
1603
|
* Prefer `collection.delete(id)` from application code — it loads the
|
|
1476
1604
|
* object first (returning `false` when not found) before calling this method.
|
|
1477
1605
|
*
|
|
1478
1606
|
* @returns Promise that resolves when deletion is complete
|
|
1607
|
+
* @throws {DatabaseError} When a referencing column declares
|
|
1608
|
+
* `onDelete: 'RESTRICT'` and rows still point at this object
|
|
1479
1609
|
*
|
|
1480
1610
|
* @example
|
|
1481
1611
|
* ```typescript
|
|
1482
1612
|
* const product = await products.get('product-uuid');
|
|
1483
1613
|
* if (product) await product.delete();
|
|
1484
1614
|
* ```
|
|
1615
|
+
*
|
|
1616
|
+
* @see {@link foreignKey} for declaring `onDelete` on the referencing side
|
|
1485
1617
|
*/
|
|
1486
1618
|
async delete() {
|
|
1487
1619
|
const interceptorContext = createInterceptorContext(this.constructor.name, "delete");
|
|
1488
1620
|
await GlobalInterceptors.executeBeforeDelete(this, interceptorContext);
|
|
1489
1621
|
await this.runHook("beforeDelete");
|
|
1490
1622
|
await this.verifyStorageReady();
|
|
1491
|
-
await this.db
|
|
1623
|
+
const { affectedTables, affectedTableClasses } = await runCascadeDelete(this.db, ObjectRegistry, {
|
|
1624
|
+
className: this.getResolvedQualifiedName(),
|
|
1625
|
+
tableName: this.tableName,
|
|
1626
|
+
id: this.id
|
|
1627
|
+
}, (db) => db.delete(this.tableName, { id: this.id }).then(() => void 0));
|
|
1492
1628
|
this._persisted = false;
|
|
1493
1629
|
this.invalidateCollectionReadCache();
|
|
1630
|
+
for (const table of affectedTables) this.invalidateCollectionReadCache(table, affectedTableClasses.get(table));
|
|
1494
1631
|
await this.runHook("afterDelete");
|
|
1495
1632
|
await GlobalInterceptors.executeAfterDelete(this, interceptorContext);
|
|
1496
1633
|
}
|
|
@@ -1504,15 +1641,21 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1504
1641
|
* broadcast to peer replicas over the database adapter's notification
|
|
1505
1642
|
* capability, fire-and-forget. Cache maintenance must never fail the
|
|
1506
1643
|
* write that triggered it.
|
|
1644
|
+
*
|
|
1645
|
+
* @param tableName - Table to invalidate; defaults to this object's own.
|
|
1646
|
+
* @param ownerQualifiedClassName - Qualified name of the class that owns
|
|
1647
|
+
* `tableName`, when it differs from this object's own class (a
|
|
1648
|
+
* cascade-affected table, #2371) — lets the broadcast decision read
|
|
1649
|
+
* *that* class's `@smrt({ cache })` config instead of only this one's.
|
|
1507
1650
|
*/
|
|
1508
|
-
invalidateCollectionReadCache() {
|
|
1651
|
+
invalidateCollectionReadCache(tableName = this.tableName, ownerQualifiedClassName) {
|
|
1509
1652
|
try {
|
|
1510
1653
|
const dbKey = resolveDbCacheKey(this.db);
|
|
1511
|
-
invalidateCollectionCache(dbKey,
|
|
1512
|
-
if (this.shouldBroadcastCacheInvalidation(dbKey)) broadcastCacheInvalidation(this.db,
|
|
1654
|
+
invalidateCollectionCache(dbKey, tableName);
|
|
1655
|
+
if (this.shouldBroadcastCacheInvalidation(dbKey, tableName, ownerQualifiedClassName)) broadcastCacheInvalidation(this.db, tableName);
|
|
1513
1656
|
} catch (error) {
|
|
1514
1657
|
logger.warn("Failed to invalidate collection read cache after write", {
|
|
1515
|
-
table:
|
|
1658
|
+
table: tableName,
|
|
1516
1659
|
error: error instanceof Error ? error.message : error
|
|
1517
1660
|
});
|
|
1518
1661
|
}
|
|
@@ -1523,14 +1666,26 @@ var SmrtObject = class SmrtObject extends SmrtClass {
|
|
|
1523
1666
|
*
|
|
1524
1667
|
* 1. A per-call `crossProcess` cached read in this process registered
|
|
1525
1668
|
* interest in the table — broadcast even without model-level config.
|
|
1526
|
-
* 2.
|
|
1669
|
+
* 2. The owning class's resolved `@smrt({ cache })` config sets
|
|
1670
|
+
* `crossProcess`. For this object's own table that class is `this`;
|
|
1671
|
+
* for a cascade-affected table (#2371) it is whatever class the
|
|
1672
|
+
* cascade plan attributed the table to, passed in as
|
|
1673
|
+
* `ownerQualifiedClassName` — a different class's table needs *that*
|
|
1674
|
+
* class's config checked, not this one's.
|
|
1527
1675
|
* 3. Any other STI hierarchy member sharing the table resolves to a
|
|
1528
1676
|
* `crossProcess` config — a child that opted out with `cache: false`
|
|
1529
1677
|
* still mutates the shared table its base/siblings are caching.
|
|
1678
|
+
*
|
|
1679
|
+
* @param ownerQualifiedClassName - Qualified class name to check rules 2
|
|
1680
|
+
* and 3 against. Defaults to this object's own resolved qualified name
|
|
1681
|
+
* when `tableName` is this object's own table; when omitted for a
|
|
1682
|
+
* foreign table (the owning class could not be resolved), rules 2/3 are
|
|
1683
|
+
* skipped and only rule 1 applies.
|
|
1530
1684
|
*/
|
|
1531
|
-
shouldBroadcastCacheInvalidation(dbKey) {
|
|
1532
|
-
if (hasCrossProcessCacheInterest(dbKey,
|
|
1533
|
-
const qualifiedName = this.getResolvedQualifiedName();
|
|
1685
|
+
shouldBroadcastCacheInvalidation(dbKey, tableName = this.tableName, ownerQualifiedClassName) {
|
|
1686
|
+
if (hasCrossProcessCacheInterest(dbKey, tableName)) return true;
|
|
1687
|
+
const qualifiedName = ownerQualifiedClassName ?? (tableName === this.tableName ? this.getResolvedQualifiedName() : void 0);
|
|
1688
|
+
if (!qualifiedName) return false;
|
|
1534
1689
|
if (ObjectRegistry.resolveCollectionCacheConfig(qualifiedName)?.crossProcess) return true;
|
|
1535
1690
|
for (const member of getSTIHierarchyMembers(qualifiedName)) {
|
|
1536
1691
|
if (member === qualifiedName) continue;
|