@happyvertical/smrt-core 0.40.70 → 0.42.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 (193) hide show
  1. package/AGENTS.md +3 -3
  2. package/agents/change-feed.md +1 -1
  3. package/agents/schema-paths.md +561 -32
  4. package/dist/browser.js +5 -4
  5. package/dist/cascade.d.ts +120 -0
  6. package/dist/cascade.d.ts.map +1 -0
  7. package/dist/cascade.js +430 -0
  8. package/dist/cascade.js.map +1 -0
  9. package/dist/change-feed.d.ts +34 -2
  10. package/dist/change-feed.d.ts.map +1 -1
  11. package/dist/change-feed.js +54 -13
  12. package/dist/change-feed.js.map +1 -1
  13. package/dist/change-signals.d.ts.map +1 -1
  14. package/dist/change-signals.js +8 -1
  15. package/dist/change-signals.js.map +1 -1
  16. package/dist/class.d.ts +36 -3
  17. package/dist/class.d.ts.map +1 -1
  18. package/dist/class.js +69 -14
  19. package/dist/class.js.map +1 -1
  20. package/dist/collection-cache.js +0 -0
  21. package/dist/collection-cache.js.map +1 -1
  22. package/dist/collection.d.ts +29 -0
  23. package/dist/collection.d.ts.map +1 -1
  24. package/dist/collection.js +68 -16
  25. package/dist/collection.js.map +1 -1
  26. package/dist/config.d.ts +10 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/decorators/index.d.ts +63 -5
  30. package/dist/decorators/index.d.ts.map +1 -1
  31. package/dist/decorators/index.js +37 -5
  32. package/dist/decorators/index.js.map +1 -1
  33. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  34. package/dist/dispatch/collections/Dispatches.js +21 -6
  35. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  36. package/dist/dispatch/models/Dispatch.d.ts +1 -1
  37. package/dist/dispatch/models/Dispatch.d.ts.map +1 -1
  38. package/dist/dispatch/models/Dispatch.js +3 -2
  39. package/dist/dispatch/models/Dispatch.js.map +1 -1
  40. package/dist/dispatch/models/DispatchSubscription.d.ts +1 -1
  41. package/dist/dispatch/models/DispatchSubscription.d.ts.map +1 -1
  42. package/dist/dispatch/models/DispatchSubscription.js +2 -1
  43. package/dist/dispatch/models/DispatchSubscription.js.map +1 -1
  44. package/dist/dispatch/types.d.ts +5 -0
  45. package/dist/dispatch/types.d.ts.map +1 -1
  46. package/dist/embedded-write-queue.d.ts +46 -0
  47. package/dist/embedded-write-queue.d.ts.map +1 -0
  48. package/dist/embedded-write-queue.js +66 -0
  49. package/dist/embedded-write-queue.js.map +1 -0
  50. package/dist/embeddings/storage.d.ts +7 -0
  51. package/dist/embeddings/storage.d.ts.map +1 -1
  52. package/dist/embeddings/storage.js +31 -13
  53. package/dist/embeddings/storage.js.map +1 -1
  54. package/dist/errors.d.ts +1 -1
  55. package/dist/hierarchical.js +1 -1
  56. package/dist/index.d.ts +2 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -6
  59. package/dist/interceptors.d.ts +21 -0
  60. package/dist/interceptors.d.ts.map +1 -1
  61. package/dist/interceptors.js +27 -1
  62. package/dist/interceptors.js.map +1 -1
  63. package/dist/learning/memory.d.ts.map +1 -1
  64. package/dist/learning/memory.js +5 -4
  65. package/dist/learning/memory.js.map +1 -1
  66. package/dist/manifest/generator.d.ts.map +1 -1
  67. package/dist/manifest/generator.js +4 -7
  68. package/dist/manifest/generator.js.map +1 -1
  69. package/dist/manifest/static-manifest.js +10 -10
  70. package/dist/manifest/static-manifest.js.map +1 -1
  71. package/dist/manifest/store.js +1 -1
  72. package/dist/manifest/store.js.map +1 -1
  73. package/dist/manifest.json +19 -19
  74. package/dist/migrations/differ.d.ts +9 -1
  75. package/dist/migrations/differ.d.ts.map +1 -1
  76. package/dist/migrations/differ.js +12 -7
  77. package/dist/migrations/differ.js.map +1 -1
  78. package/dist/migrations/index.d.ts +2 -0
  79. package/dist/migrations/index.d.ts.map +1 -1
  80. package/dist/migrations/index.js +3 -1
  81. package/dist/migrations/integer-width.d.ts +99 -0
  82. package/dist/migrations/integer-width.d.ts.map +1 -0
  83. package/dist/migrations/integer-width.js +237 -0
  84. package/dist/migrations/integer-width.js.map +1 -0
  85. package/dist/migrations/minor-units.d.ts +162 -0
  86. package/dist/migrations/minor-units.d.ts.map +1 -0
  87. package/dist/migrations/minor-units.js +383 -0
  88. package/dist/migrations/minor-units.js.map +1 -0
  89. package/dist/migrations/tracker.d.ts.map +1 -1
  90. package/dist/migrations/tracker.js +28 -20
  91. package/dist/migrations/tracker.js.map +1 -1
  92. package/dist/migrations.js +3 -1
  93. package/dist/object.d.ts +98 -5
  94. package/dist/object.d.ts.map +1 -1
  95. package/dist/object.js +138 -30
  96. package/dist/object.js.map +1 -1
  97. package/dist/query-bounds.d.ts +5 -1
  98. package/dist/query-bounds.d.ts.map +1 -1
  99. package/dist/query-bounds.js +5 -1
  100. package/dist/query-bounds.js.map +1 -1
  101. package/dist/registry/class-registration.d.ts.map +1 -1
  102. package/dist/registry/class-registration.js +3 -1
  103. package/dist/registry/class-registration.js.map +1 -1
  104. package/dist/registry/manifest-field-merge.d.ts +12 -0
  105. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  106. package/dist/registry/manifest-field-merge.js +14 -2
  107. package/dist/registry/manifest-field-merge.js.map +1 -1
  108. package/dist/registry/schema-builder.d.ts +22 -1
  109. package/dist/registry/schema-builder.d.ts.map +1 -1
  110. package/dist/registry/schema-builder.js +205 -165
  111. package/dist/registry/schema-builder.js.map +1 -1
  112. package/dist/registry/types.d.ts +3 -2
  113. package/dist/registry/types.d.ts.map +1 -1
  114. package/dist/registry.d.ts +41 -46
  115. package/dist/registry.d.ts.map +1 -1
  116. package/dist/registry.js +61 -83
  117. package/dist/registry.js.map +1 -1
  118. package/dist/scanner/manifest-generator.d.ts +45 -0
  119. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  120. package/dist/scanner/manifest-generator.js +92 -28
  121. package/dist/scanner/manifest-generator.js.map +1 -1
  122. package/dist/schema/conflict-target.d.ts +104 -0
  123. package/dist/schema/conflict-target.d.ts.map +1 -0
  124. package/dist/schema/conflict-target.js +129 -0
  125. package/dist/schema/conflict-target.js.map +1 -0
  126. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  127. package/dist/schema/ddl/duckdb-strategy.js +3 -1
  128. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  129. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  130. package/dist/schema/ddl/postgres-strategy.js +14 -1
  131. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  132. package/dist/schema/generator.d.ts +245 -41
  133. package/dist/schema/generator.d.ts.map +1 -1
  134. package/dist/schema/generator.js +397 -241
  135. package/dist/schema/generator.js.map +1 -1
  136. package/dist/schema/index-utils.d.ts +120 -0
  137. package/dist/schema/index-utils.d.ts.map +1 -1
  138. package/dist/schema/index-utils.js +242 -1
  139. package/dist/schema/index-utils.js.map +1 -1
  140. package/dist/schema/index.d.ts +2 -3
  141. package/dist/schema/index.d.ts.map +1 -1
  142. package/dist/schema/index.js +2 -3
  143. package/dist/schema/live-parity.d.ts +3 -1
  144. package/dist/schema/live-parity.d.ts.map +1 -1
  145. package/dist/schema/live-parity.js +23 -2
  146. package/dist/schema/live-parity.js.map +1 -1
  147. package/dist/schema/system-table-shapes.d.ts +13 -0
  148. package/dist/schema/system-table-shapes.d.ts.map +1 -1
  149. package/dist/schema/system-table-shapes.js +14 -1
  150. package/dist/schema/system-table-shapes.js.map +1 -1
  151. package/dist/schema/types.d.ts +17 -10
  152. package/dist/schema/types.d.ts.map +1 -1
  153. package/dist/schema/utils.d.ts +1 -1
  154. package/dist/schema/utils.d.ts.map +1 -1
  155. package/dist/schema/utils.js +3 -3
  156. package/dist/schema/utils.js.map +1 -1
  157. package/dist/schema.js +2 -3
  158. package/dist/smrt-knowledge.json +9 -9
  159. package/dist/system/compatibility.d.ts +42 -0
  160. package/dist/system/compatibility.d.ts.map +1 -1
  161. package/dist/system/compatibility.js +182 -9
  162. package/dist/system/compatibility.js.map +1 -1
  163. package/dist/system/index.d.ts +1 -0
  164. package/dist/system/index.d.ts.map +1 -1
  165. package/dist/system/index.js +3 -2
  166. package/dist/system/retention.d.ts +237 -0
  167. package/dist/system/retention.d.ts.map +1 -0
  168. package/dist/system/retention.js +497 -0
  169. package/dist/system/retention.js.map +1 -0
  170. package/dist/system/schema.d.ts +102 -16
  171. package/dist/system/schema.d.ts.map +1 -1
  172. package/dist/system/schema.js +85 -47
  173. package/dist/system/schema.js.map +1 -1
  174. package/dist/system/types.d.ts +0 -2
  175. package/dist/system/types.d.ts.map +1 -1
  176. package/dist/testing/database.d.ts.map +1 -1
  177. package/dist/testing/database.js.map +1 -1
  178. package/dist/utils/safe-integer.d.ts +19 -0
  179. package/dist/utils/safe-integer.d.ts.map +1 -0
  180. package/dist/utils/safe-integer.js +31 -0
  181. package/dist/utils/safe-integer.js.map +1 -0
  182. package/dist/utils.d.ts +2 -1
  183. package/dist/utils.d.ts.map +1 -1
  184. package/dist/utils.js +6 -3
  185. package/dist/utils.js.map +1 -1
  186. package/dist/vite-plugin/index.d.ts.map +1 -1
  187. package/dist/vite-plugin/index.js +4 -62
  188. package/dist/vite-plugin/index.js.map +1 -1
  189. package/package.json +4 -4
  190. package/dist/schema/override-system.d.ts +0 -43
  191. package/dist/schema/override-system.d.ts.map +0 -1
  192. package/dist/schema/override-system.js +0 -205
  193. package/dist/schema/override-system.js.map +0 -1
package/dist/object.js CHANGED
@@ -1,15 +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 { classifyDatabaseError, classifyDialectMessage } from "./db-errors.js";
7
- import { DatabaseError, ErrorUtils, RuntimeError, SmrtError, TenantIsolationError, ValidationError } from "./errors.js";
8
- import { toSnakeCase } from "./utils/naming.js";
9
11
  import { ContentHasher } from "./embeddings/hash.js";
10
12
  import { verifyPersistenceTable } from "./schema/table-verifier.js";
11
13
  import { executeToolCall } from "./tools/tool-executor.js";
12
- import { fieldsFromClass, tableNameFromClass } from "./utils.js";
14
+ import { fieldsFromClass, keysToSnakeCase, tableNameFromClass } from "./utils.js";
13
15
  import { ObjectRegistry } from "./registry.js";
14
16
  import { createLogger } from "@happyvertical/logger";
15
17
  //#region src/object.ts
@@ -659,9 +661,9 @@ var SmrtObject = class SmrtObject extends SmrtClass {
659
661
  const value = this.getPropertyValue(key);
660
662
  if (value === void 0) {
661
663
  const fieldType = prop && typeof prop === "object" && "type" in prop && prop.type || fieldDef?.type;
662
- 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;
663
665
  else data[key] = null;
664
- else if (isSTI && fieldDef?.type === "meta") metaData[key] = "";
666
+ else if (fieldType === "text") if (isSTI && fieldDef?.type === "meta") metaData[key] = "";
665
667
  else data[key] = "";
666
668
  else if (fieldType === "json") {
667
669
  const defaultValue = fieldDef?.default ?? null;
@@ -801,15 +803,21 @@ var SmrtObject = class SmrtObject extends SmrtClass {
801
803
  /**
802
804
  * Gets or generates a unique ID for this object
803
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
+ *
804
812
  * @returns Promise resolving to the object's ID
805
813
  */
806
814
  async getId() {
807
815
  if (this.slug) {
808
816
  await this.verifyStorageReady();
809
- const saved = await this.db.get(this.tableName, {
817
+ const saved = await this.db.get(this.tableName, await this.interceptGetFilter({
810
818
  slug: this.slug,
811
819
  context: this.context
812
- });
820
+ }));
813
821
  if (saved) this.id = saved.id;
814
822
  }
815
823
  if (!this.id) this.id = crypto.randomUUID();
@@ -850,22 +858,56 @@ var SmrtObject = class SmrtObject extends SmrtClass {
850
858
  /**
851
859
  * Gets the ID of this object if it's already saved in the database
852
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
+ *
853
865
  * @returns Promise resolving to the saved ID or null if not saved
854
866
  */
855
867
  async getSavedId() {
856
868
  if (!this.id && !this.slug) return null;
857
869
  await this.verifyStorageReady();
858
870
  if (this.id) {
859
- 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 }));
860
872
  if (byId) return byId.id;
861
873
  }
862
874
  if (this.slug) {
863
- 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 }));
864
876
  if (bySlug) return bySlug.id;
865
877
  }
866
878
  return null;
867
879
  }
868
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
+ /**
869
911
  * Checks if this object is already saved in the database
870
912
  *
871
913
  * @returns Promise resolving to true if saved, false otherwise
@@ -1025,7 +1067,8 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1025
1067
  const conflictColumns = ObjectRegistry.getConflictColumns(className);
1026
1068
  const writePlan = await this.planPersistenceWrite(className, tableStrategy, data, conflictColumns);
1027
1069
  const upsertConflictColumns = this._persisted && data.id ? ["id"] : conflictColumns;
1028
- 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 () => {
1029
1072
  try {
1030
1073
  if (writePlan.type === "updateById") {
1031
1074
  const { id: _id, ...updateData } = data;
@@ -1049,7 +1092,7 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1049
1092
  }
1050
1093
  throw error;
1051
1094
  }
1052
- }, 3, 500);
1095
+ }, 3, 500));
1053
1096
  this._persisted = true;
1054
1097
  this.invalidateCollectionReadCache();
1055
1098
  await GlobalInterceptors.executeAfterSave(this, interceptorContext);
@@ -1246,8 +1289,10 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1246
1289
  /**
1247
1290
  * Hydrates this object from the database using its `id` property.
1248
1291
  *
1249
- * Queries the database for a row matching `{ id: this._id }` and calls
1250
- * `loadDataFromDb()` if found. Transient failures are retried up to 4 times
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
1251
1296
  * total — the initial try plus 3 retries — sleeping 250, 500 and 1000 ms
1252
1297
  * between them; deterministic failures are not. A malformed identifier
1253
1298
  * (PostgreSQL `22P02 invalid_text_representation` on a `uuid` column) is a
@@ -1271,12 +1316,13 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1271
1316
  * ```
1272
1317
  */
1273
1318
  async loadFromId() {
1319
+ if (!this._id) throw ValidationError.requiredField("id", this.constructor.name);
1320
+ const filter = await this.interceptGetFilter({ id: this._id });
1274
1321
  try {
1275
- if (!this._id) throw ValidationError.requiredField("id", this.constructor.name);
1276
1322
  await this.verifyStorageReady();
1277
1323
  await ErrorUtils.withRetry(async () => {
1278
1324
  try {
1279
- const existing = await this.db.get(this.tableName, { id: this._id });
1325
+ const existing = await this.db.get(this.tableName, filter);
1280
1326
  if (existing) await this.loadDataFromDb(existing);
1281
1327
  } catch (error) {
1282
1328
  throw DatabaseError.queryFailed(`get(${this.tableName}, { id: ${this._id} })`, error instanceof Error ? error : new Error(String(error)));
@@ -1308,10 +1354,10 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1308
1354
  */
1309
1355
  async loadFromSlug() {
1310
1356
  await this.verifyStorageReady();
1311
- const existing = await this.db.get(this.tableName, {
1357
+ const existing = await this.db.get(this.tableName, await this.interceptGetFilter({
1312
1358
  slug: this._slug,
1313
1359
  context: this._context || ""
1314
- });
1360
+ }));
1315
1361
  if (existing) await this.loadDataFromDb(existing);
1316
1362
  }
1317
1363
  /**
@@ -1510,34 +1556,78 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1510
1556
  } else if (typeof hook === "function") await hook(this);
1511
1557
  }
1512
1558
  /**
1513
- * Deletes this object from the database.
1559
+ * Deletes this object from the database, cascading to the rows that
1560
+ * reference it.
1514
1561
  *
1515
1562
  * Runs the full lifecycle in order:
1516
1563
  * 1. `beforeDelete` interceptors (e.g. tenant validation)
1517
1564
  * 2. `beforeDelete` lifecycle hook (defined in `@smrt({ hooks })`)
1518
- * 3. Database row deletion
1565
+ * 3. Referential cleanup and the row deletion (one transaction when there
1566
+ * is anything to clean up — see below)
1519
1567
  * 4. `afterDelete` lifecycle hook
1520
1568
  * 5. `afterDelete` interceptors
1521
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
+ *
1522
1603
  * Prefer `collection.delete(id)` from application code — it loads the
1523
1604
  * object first (returning `false` when not found) before calling this method.
1524
1605
  *
1525
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
1526
1609
  *
1527
1610
  * @example
1528
1611
  * ```typescript
1529
1612
  * const product = await products.get('product-uuid');
1530
1613
  * if (product) await product.delete();
1531
1614
  * ```
1615
+ *
1616
+ * @see {@link foreignKey} for declaring `onDelete` on the referencing side
1532
1617
  */
1533
1618
  async delete() {
1534
1619
  const interceptorContext = createInterceptorContext(this.constructor.name, "delete");
1535
1620
  await GlobalInterceptors.executeBeforeDelete(this, interceptorContext);
1536
1621
  await this.runHook("beforeDelete");
1537
1622
  await this.verifyStorageReady();
1538
- 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));
1539
1628
  this._persisted = false;
1540
1629
  this.invalidateCollectionReadCache();
1630
+ for (const table of affectedTables) this.invalidateCollectionReadCache(table, affectedTableClasses.get(table));
1541
1631
  await this.runHook("afterDelete");
1542
1632
  await GlobalInterceptors.executeAfterDelete(this, interceptorContext);
1543
1633
  }
@@ -1551,15 +1641,21 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1551
1641
  * broadcast to peer replicas over the database adapter's notification
1552
1642
  * capability, fire-and-forget. Cache maintenance must never fail the
1553
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.
1554
1650
  */
1555
- invalidateCollectionReadCache() {
1651
+ invalidateCollectionReadCache(tableName = this.tableName, ownerQualifiedClassName) {
1556
1652
  try {
1557
1653
  const dbKey = resolveDbCacheKey(this.db);
1558
- invalidateCollectionCache(dbKey, this.tableName);
1559
- if (this.shouldBroadcastCacheInvalidation(dbKey)) broadcastCacheInvalidation(this.db, this.tableName);
1654
+ invalidateCollectionCache(dbKey, tableName);
1655
+ if (this.shouldBroadcastCacheInvalidation(dbKey, tableName, ownerQualifiedClassName)) broadcastCacheInvalidation(this.db, tableName);
1560
1656
  } catch (error) {
1561
1657
  logger.warn("Failed to invalidate collection read cache after write", {
1562
- table: this.tableName,
1658
+ table: tableName,
1563
1659
  error: error instanceof Error ? error.message : error
1564
1660
  });
1565
1661
  }
@@ -1570,14 +1666,26 @@ var SmrtObject = class SmrtObject extends SmrtClass {
1570
1666
  *
1571
1667
  * 1. A per-call `crossProcess` cached read in this process registered
1572
1668
  * interest in the table — broadcast even without model-level config.
1573
- * 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.
1574
1675
  * 3. Any other STI hierarchy member sharing the table resolves to a
1575
1676
  * `crossProcess` config — a child that opted out with `cache: false`
1576
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.
1577
1684
  */
1578
- shouldBroadcastCacheInvalidation(dbKey) {
1579
- if (hasCrossProcessCacheInterest(dbKey, this.tableName)) return true;
1580
- 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;
1581
1689
  if (ObjectRegistry.resolveCollectionCacheConfig(qualifiedName)?.crossProcess) return true;
1582
1690
  for (const member of getSTIHierarchyMembers(qualifiedName)) {
1583
1691
  if (member === qualifiedName) continue;