@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.
Files changed (237) hide show
  1. package/AGENTS.md +29 -4
  2. package/README.md +20 -1
  3. package/agents/change-feed.md +1 -1
  4. package/agents/query-bounds.md +45 -0
  5. package/agents/schema-paths.md +786 -0
  6. package/dist/browser.d.ts +1 -0
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +5 -3
  9. package/dist/cascade.d.ts +120 -0
  10. package/dist/cascade.d.ts.map +1 -0
  11. package/dist/cascade.js +430 -0
  12. package/dist/cascade.js.map +1 -0
  13. package/dist/change-feed.d.ts +34 -2
  14. package/dist/change-feed.d.ts.map +1 -1
  15. package/dist/change-feed.js +52 -11
  16. package/dist/change-feed.js.map +1 -1
  17. package/dist/class.d.ts +36 -3
  18. package/dist/class.d.ts.map +1 -1
  19. package/dist/class.js +87 -9
  20. package/dist/class.js.map +1 -1
  21. package/dist/collection-cache.js +0 -0
  22. package/dist/collection-cache.js.map +1 -1
  23. package/dist/collection.d.ts +130 -2
  24. package/dist/collection.d.ts.map +1 -1
  25. package/dist/collection.js +290 -57
  26. package/dist/collection.js.map +1 -1
  27. package/dist/config.d.ts +10 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/database.d.ts +8 -0
  31. package/dist/database.d.ts.map +1 -1
  32. package/dist/database.js +16 -8
  33. package/dist/database.js.map +1 -1
  34. package/dist/db-errors.d.ts +105 -0
  35. package/dist/db-errors.d.ts.map +1 -0
  36. package/dist/db-errors.js +382 -0
  37. package/dist/db-errors.js.map +1 -0
  38. package/dist/decorators/index.d.ts +80 -6
  39. package/dist/decorators/index.d.ts.map +1 -1
  40. package/dist/decorators/index.js +102 -12
  41. package/dist/decorators/index.js.map +1 -1
  42. package/dist/dispatch/bus.d.ts.map +1 -1
  43. package/dist/dispatch/bus.js +4 -3
  44. package/dist/dispatch/bus.js.map +1 -1
  45. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  46. package/dist/dispatch/collections/Dispatches.js +19 -4
  47. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  48. package/dist/dispatch/types.d.ts +5 -0
  49. package/dist/dispatch/types.d.ts.map +1 -1
  50. package/dist/embedded-write-queue.d.ts +46 -0
  51. package/dist/embedded-write-queue.d.ts.map +1 -0
  52. package/dist/embedded-write-queue.js +66 -0
  53. package/dist/embedded-write-queue.js.map +1 -0
  54. package/dist/embeddings/storage.d.ts +7 -0
  55. package/dist/embeddings/storage.d.ts.map +1 -1
  56. package/dist/embeddings/storage.js +29 -12
  57. package/dist/embeddings/storage.js.map +1 -1
  58. package/dist/errors.d.ts +31 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js +34 -2
  61. package/dist/errors.js.map +1 -1
  62. package/dist/generators/changes-route.d.ts.map +1 -1
  63. package/dist/generators/changes-route.js +6 -3
  64. package/dist/generators/changes-route.js.map +1 -1
  65. package/dist/generators/mcp-runtime-template.d.ts +8 -0
  66. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  67. package/dist/generators/mcp-runtime-template.js +38 -4
  68. package/dist/generators/mcp-runtime-template.js.map +1 -1
  69. package/dist/generators/mcp.d.ts +16 -0
  70. package/dist/generators/mcp.d.ts.map +1 -1
  71. package/dist/generators/mcp.js +41 -3
  72. package/dist/generators/mcp.js.map +1 -1
  73. package/dist/generators/rest.d.ts +22 -0
  74. package/dist/generators/rest.d.ts.map +1 -1
  75. package/dist/generators/rest.js +34 -3
  76. package/dist/generators/rest.js.map +1 -1
  77. package/dist/hierarchical.js +1 -1
  78. package/dist/index.d.ts +7 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +12 -5
  81. package/dist/interceptors.d.ts +21 -0
  82. package/dist/interceptors.d.ts.map +1 -1
  83. package/dist/interceptors.js +27 -1
  84. package/dist/interceptors.js.map +1 -1
  85. package/dist/manifest/generator.d.ts.map +1 -1
  86. package/dist/manifest/generator.js +4 -7
  87. package/dist/manifest/generator.js.map +1 -1
  88. package/dist/manifest/static-manifest.js +10 -10
  89. package/dist/manifest/static-manifest.js.map +1 -1
  90. package/dist/manifest/store.js +1 -1
  91. package/dist/manifest/store.js.map +1 -1
  92. package/dist/manifest.json +19 -19
  93. package/dist/migrations/differ.d.ts +211 -9
  94. package/dist/migrations/differ.d.ts.map +1 -1
  95. package/dist/migrations/differ.js +613 -50
  96. package/dist/migrations/differ.js.map +1 -1
  97. package/dist/migrations/generator.d.ts +31 -4
  98. package/dist/migrations/generator.d.ts.map +1 -1
  99. package/dist/migrations/generator.js +49 -5
  100. package/dist/migrations/generator.js.map +1 -1
  101. package/dist/migrations/index.d.ts +4 -2
  102. package/dist/migrations/index.d.ts.map +1 -1
  103. package/dist/migrations/index.js +6 -3
  104. package/dist/migrations/minor-units.d.ts +162 -0
  105. package/dist/migrations/minor-units.d.ts.map +1 -0
  106. package/dist/migrations/minor-units.js +381 -0
  107. package/dist/migrations/minor-units.js.map +1 -0
  108. package/dist/migrations/orchestrate.js +35 -6
  109. package/dist/migrations/orchestrate.js.map +1 -1
  110. package/dist/migrations/sqlite-rebuild.d.ts +142 -0
  111. package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
  112. package/dist/migrations/sqlite-rebuild.js +514 -0
  113. package/dist/migrations/sqlite-rebuild.js.map +1 -0
  114. package/dist/migrations/tracker.d.ts +114 -1
  115. package/dist/migrations/tracker.d.ts.map +1 -1
  116. package/dist/migrations/tracker.js +331 -16
  117. package/dist/migrations/tracker.js.map +1 -1
  118. package/dist/migrations/types.d.ts +19 -4
  119. package/dist/migrations/types.d.ts.map +1 -1
  120. package/dist/migrations.js +6 -3
  121. package/dist/object.d.ts +142 -10
  122. package/dist/object.d.ts.map +1 -1
  123. package/dist/object.js +196 -41
  124. package/dist/object.js.map +1 -1
  125. package/dist/postgres-timeouts.d.ts +240 -0
  126. package/dist/postgres-timeouts.d.ts.map +1 -0
  127. package/dist/postgres-timeouts.js +204 -0
  128. package/dist/postgres-timeouts.js.map +1 -0
  129. package/dist/query-bounds.d.ts +101 -0
  130. package/dist/query-bounds.d.ts.map +1 -0
  131. package/dist/query-bounds.js +177 -0
  132. package/dist/query-bounds.js.map +1 -0
  133. package/dist/registry/class-registration.d.ts.map +1 -1
  134. package/dist/registry/class-registration.js +3 -1
  135. package/dist/registry/class-registration.js.map +1 -1
  136. package/dist/registry/manifest-field-merge.d.ts +12 -0
  137. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  138. package/dist/registry/manifest-field-merge.js +14 -2
  139. package/dist/registry/manifest-field-merge.js.map +1 -1
  140. package/dist/registry/schema-builder.d.ts +22 -1
  141. package/dist/registry/schema-builder.d.ts.map +1 -1
  142. package/dist/registry/schema-builder.js +205 -165
  143. package/dist/registry/schema-builder.js.map +1 -1
  144. package/dist/registry/types.d.ts +35 -3
  145. package/dist/registry/types.d.ts.map +1 -1
  146. package/dist/registry.d.ts +41 -46
  147. package/dist/registry.d.ts.map +1 -1
  148. package/dist/registry.js +61 -83
  149. package/dist/registry.js.map +1 -1
  150. package/dist/scanner/manifest-generator.d.ts +45 -0
  151. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  152. package/dist/scanner/manifest-generator.js +92 -28
  153. package/dist/scanner/manifest-generator.js.map +1 -1
  154. package/dist/scanner/types.d.ts +5 -0
  155. package/dist/scanner/types.d.ts.map +1 -1
  156. package/dist/scanner/types.js.map +1 -1
  157. package/dist/schema/conflict-target.d.ts +104 -0
  158. package/dist/schema/conflict-target.d.ts.map +1 -0
  159. package/dist/schema/conflict-target.js +129 -0
  160. package/dist/schema/conflict-target.js.map +1 -0
  161. package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
  162. package/dist/schema/ddl/base-strategy.js +2 -2
  163. package/dist/schema/ddl/base-strategy.js.map +1 -1
  164. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  165. package/dist/schema/ddl/duckdb-strategy.js +2 -1
  166. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  167. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  168. package/dist/schema/ddl/postgres-strategy.js +12 -1
  169. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  170. package/dist/schema/generator.d.ts +307 -6
  171. package/dist/schema/generator.d.ts.map +1 -1
  172. package/dist/schema/generator.js +510 -87
  173. package/dist/schema/generator.js.map +1 -1
  174. package/dist/schema/index-utils.d.ts +120 -0
  175. package/dist/schema/index-utils.d.ts.map +1 -1
  176. package/dist/schema/index-utils.js +242 -1
  177. package/dist/schema/index-utils.js.map +1 -1
  178. package/dist/schema/index.d.ts +3 -0
  179. package/dist/schema/index.d.ts.map +1 -1
  180. package/dist/schema/index.js +4 -1
  181. package/dist/schema/live-parity.d.ts +90 -0
  182. package/dist/schema/live-parity.d.ts.map +1 -0
  183. package/dist/schema/live-parity.js +602 -0
  184. package/dist/schema/live-parity.js.map +1 -0
  185. package/dist/schema/manifest-schema.d.ts +121 -0
  186. package/dist/schema/manifest-schema.d.ts.map +1 -0
  187. package/dist/schema/manifest-schema.js +267 -0
  188. package/dist/schema/manifest-schema.js.map +1 -0
  189. package/dist/schema/schema-aggregator.d.ts +24 -10
  190. package/dist/schema/schema-aggregator.d.ts.map +1 -1
  191. package/dist/schema/schema-aggregator.js +35 -90
  192. package/dist/schema/schema-aggregator.js.map +1 -1
  193. package/dist/schema/system-table-shapes.d.ts +65 -0
  194. package/dist/schema/system-table-shapes.d.ts.map +1 -0
  195. package/dist/schema/system-table-shapes.js +187 -0
  196. package/dist/schema/system-table-shapes.js.map +1 -0
  197. package/dist/schema/types.d.ts +103 -4
  198. package/dist/schema/types.d.ts.map +1 -1
  199. package/dist/schema/utils.d.ts +2 -1
  200. package/dist/schema/utils.d.ts.map +1 -1
  201. package/dist/schema/utils.js +5 -3
  202. package/dist/schema/utils.js.map +1 -1
  203. package/dist/schema.js +4 -1
  204. package/dist/smrt-knowledge.json +20 -8
  205. package/dist/sync/apply.d.ts.map +1 -1
  206. package/dist/sync/apply.js +9 -16
  207. package/dist/sync/apply.js.map +1 -1
  208. package/dist/system/compatibility.d.ts +42 -0
  209. package/dist/system/compatibility.d.ts.map +1 -1
  210. package/dist/system/compatibility.js +182 -9
  211. package/dist/system/compatibility.js.map +1 -1
  212. package/dist/system/index.d.ts +1 -0
  213. package/dist/system/index.d.ts.map +1 -1
  214. package/dist/system/index.js +3 -2
  215. package/dist/system/retention.d.ts +237 -0
  216. package/dist/system/retention.d.ts.map +1 -0
  217. package/dist/system/retention.js +497 -0
  218. package/dist/system/retention.js.map +1 -0
  219. package/dist/system/schema.d.ts +100 -15
  220. package/dist/system/schema.d.ts.map +1 -1
  221. package/dist/system/schema.js +81 -45
  222. package/dist/system/schema.js.map +1 -1
  223. package/dist/system/types.d.ts +0 -2
  224. package/dist/system/types.d.ts.map +1 -1
  225. package/dist/testing/database.d.ts.map +1 -1
  226. package/dist/testing/database.js +1 -0
  227. package/dist/testing/database.js.map +1 -1
  228. package/dist/vite-plugin/index.d.ts.map +1 -1
  229. package/dist/vite-plugin/index.js +4 -9
  230. package/dist/vite-plugin/index.js.map +1 -1
  231. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  232. package/dist/vite-plugin/sveltekit-generator.js +71 -5
  233. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  234. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  235. package/dist/vite-plugin/web-collections.js +6 -4
  236. package/dist/vite-plugin/web-collections.js.map +1 -1
  237. 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 { GlobalInterceptors, createInterceptorContext } from "./interceptors.js";
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 (fieldType === "text") 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;
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 with automatic retry (3 attempts, 500 ms backoff)
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} If a required field is missing or a unique constraint is violated
960
- * @throws {DatabaseError} If the table does not exist (`DB_SCHEMA_MISSING`) or the query fails
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
- await ErrorUtils.withRetry(async () => {
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 kind = SmrtObject.classifyConstraintError(error.message);
1020
- if (kind === "unique") {
1021
- const field = this.extractConstraintField(error.message);
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 === "not_null") {
1025
- const field = this.extractConstraintField(error.message);
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
- if (/NOT NULL constraint failed/i.test(message) || /null value in column .* violates not-null/i.test(message)) return "not_null";
1182
- if (/UNIQUE constraint failed/i.test(message) || /violates unique constraint/i.test(message) || /violates primary key constraint/i.test(message)) return "unique";
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 }` and calls
1208
- * `loadDataFromDb()` if found. Uses a 3-attempt retry with 250 ms initial
1209
- * delay to handle transient failures.
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 after all retries
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, { id: this._id });
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. Database row deletion
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.delete(this.tableName, { id: this.id });
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, this.tableName);
1512
- if (this.shouldBroadcastCacheInvalidation(dbKey)) broadcastCacheInvalidation(this.db, this.tableName);
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: this.tableName,
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. This class's resolved `@smrt({ cache })` config sets `crossProcess`.
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, this.tableName)) return true;
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;