@warlock.js/cascade 5.19.1 → 5.21.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 (194) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/cjs/index.cjs +2356 -1039
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/context/database-transaction-context.d.mts +10 -0
  5. package/esm/context/database-transaction-context.d.mts.map +1 -1
  6. package/esm/context/database-transaction-context.mjs +17 -0
  7. package/esm/context/database-transaction-context.mjs.map +1 -1
  8. package/esm/contracts/query-builder.contract.d.mts +30 -3
  9. package/esm/contracts/query-builder.contract.d.mts.map +1 -1
  10. package/esm/data-source/data-source-registry.d.mts +33 -1
  11. package/esm/data-source/data-source-registry.d.mts.map +1 -1
  12. package/esm/data-source/data-source-registry.mjs +75 -5
  13. package/esm/data-source/data-source-registry.mjs.map +1 -1
  14. package/esm/database-dirty-tracker.d.mts +5 -0
  15. package/esm/database-dirty-tracker.d.mts.map +1 -1
  16. package/esm/database-dirty-tracker.mjs +16 -0
  17. package/esm/database-dirty-tracker.mjs.map +1 -1
  18. package/esm/drivers/mongodb/mongodb-driver.d.mts +12 -0
  19. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  20. package/esm/drivers/mongodb/mongodb-driver.mjs +58 -19
  21. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  22. package/esm/drivers/mongodb/mongodb-migration-driver.d.mts +1 -1
  23. package/esm/drivers/mongodb/mongodb-migration-driver.d.mts.map +1 -1
  24. package/esm/drivers/mongodb/mongodb-migration-driver.mjs +15 -18
  25. package/esm/drivers/mongodb/mongodb-migration-driver.mjs.map +1 -1
  26. package/esm/drivers/mongodb/mongodb-query-builder.d.mts +13 -3
  27. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  28. package/esm/drivers/mongodb/mongodb-query-builder.mjs +72 -19
  29. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  30. package/esm/drivers/mongodb/mongodb-query-parser.d.mts +6 -0
  31. package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
  32. package/esm/drivers/mongodb/mongodb-query-parser.mjs +128 -30
  33. package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
  34. package/esm/drivers/postgres/index.d.mts +2 -3
  35. package/esm/drivers/postgres/index.mjs +1 -2
  36. package/esm/drivers/postgres/postgres-blueprint.mjs +4 -4
  37. package/esm/drivers/postgres/postgres-blueprint.mjs.map +1 -1
  38. package/esm/drivers/postgres/postgres-dialect.d.mts +5 -1
  39. package/esm/drivers/postgres/postgres-dialect.d.mts.map +1 -1
  40. package/esm/drivers/postgres/postgres-dialect.mjs +38 -8
  41. package/esm/drivers/postgres/postgres-dialect.mjs.map +1 -1
  42. package/esm/drivers/postgres/postgres-driver.d.mts +52 -29
  43. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  44. package/esm/drivers/postgres/postgres-driver.mjs +196 -58
  45. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  46. package/esm/drivers/postgres/postgres-migration-driver.d.mts +2 -1
  47. package/esm/drivers/postgres/postgres-migration-driver.d.mts.map +1 -1
  48. package/esm/drivers/postgres/postgres-migration-driver.mjs +8 -6
  49. package/esm/drivers/postgres/postgres-migration-driver.mjs.map +1 -1
  50. package/esm/drivers/postgres/postgres-query-builder.d.mts +18 -7
  51. package/esm/drivers/postgres/postgres-query-builder.d.mts.map +1 -1
  52. package/esm/drivers/postgres/postgres-query-builder.mjs +146 -31
  53. package/esm/drivers/postgres/postgres-query-builder.mjs.map +1 -1
  54. package/esm/drivers/postgres/postgres-query-parser.d.mts +23 -1
  55. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  56. package/esm/drivers/postgres/postgres-query-parser.mjs +160 -43
  57. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  58. package/esm/drivers/postgres/postgres-sql-serializer.mjs +3 -2
  59. package/esm/drivers/postgres/postgres-sql-serializer.mjs.map +1 -1
  60. package/esm/drivers/postgres/postgres-type-converters.mjs +57 -0
  61. package/esm/drivers/postgres/postgres-type-converters.mjs.map +1 -0
  62. package/esm/events/model-events.d.mts +1 -1
  63. package/esm/events/model-events.d.mts.map +1 -1
  64. package/esm/events/model-events.mjs.map +1 -1
  65. package/esm/index.d.mts +6 -6
  66. package/esm/index.mjs +5 -5
  67. package/esm/migration/column-builder.d.mts.map +1 -1
  68. package/esm/migration/column-builder.mjs +2 -2
  69. package/esm/migration/column-builder.mjs.map +1 -1
  70. package/esm/migration/index.d.mts +2 -2
  71. package/esm/migration/index.mjs +1 -1
  72. package/esm/migration/migration-order.mjs +5 -2
  73. package/esm/migration/migration-order.mjs.map +1 -1
  74. package/esm/migration/migration-runner.d.mts +50 -5
  75. package/esm/migration/migration-runner.d.mts.map +1 -1
  76. package/esm/migration/migration-runner.mjs +370 -147
  77. package/esm/migration/migration-runner.mjs.map +1 -1
  78. package/esm/migration/migration.d.mts +36 -3
  79. package/esm/migration/migration.d.mts.map +1 -1
  80. package/esm/migration/migration.mjs +62 -5
  81. package/esm/migration/migration.mjs.map +1 -1
  82. package/esm/model/methods/accessor-methods.mjs +1 -3
  83. package/esm/model/methods/accessor-methods.mjs.map +1 -1
  84. package/esm/model/methods/delete-methods.mjs +6 -2
  85. package/esm/model/methods/delete-methods.mjs.map +1 -1
  86. package/esm/model/methods/hydration-methods.mjs +16 -2
  87. package/esm/model/methods/hydration-methods.mjs.map +1 -1
  88. package/esm/model/methods/instance-event-methods.mjs +2 -1
  89. package/esm/model/methods/instance-event-methods.mjs.map +1 -1
  90. package/esm/model/methods/meta-methods.mjs +53 -16
  91. package/esm/model/methods/meta-methods.mjs.map +1 -1
  92. package/esm/model/methods/query-methods.mjs +86 -15
  93. package/esm/model/methods/query-methods.mjs.map +1 -1
  94. package/esm/model/methods/static-event-methods.mjs +16 -1
  95. package/esm/model/methods/static-event-methods.mjs.map +1 -1
  96. package/esm/model/methods/write-methods.d.mts.map +1 -1
  97. package/esm/model/methods/write-methods.mjs +50 -50
  98. package/esm/model/methods/write-methods.mjs.map +1 -1
  99. package/esm/model/model.d.mts +16 -1
  100. package/esm/model/model.d.mts.map +1 -1
  101. package/esm/model/model.mjs +39 -5
  102. package/esm/model/model.mjs.map +1 -1
  103. package/esm/model/register-model.d.mts +6 -1
  104. package/esm/model/register-model.d.mts.map +1 -1
  105. package/esm/model/register-model.mjs +16 -1
  106. package/esm/model/register-model.mjs.map +1 -1
  107. package/esm/query-builder/query-builder.d.mts +21 -2
  108. package/esm/query-builder/query-builder.d.mts.map +1 -1
  109. package/esm/query-builder/query-builder.mjs +97 -20
  110. package/esm/query-builder/query-builder.mjs.map +1 -1
  111. package/esm/relations/key-conventions.mjs +13 -3
  112. package/esm/relations/key-conventions.mjs.map +1 -1
  113. package/esm/relations/pivot-operations.mjs +1 -1
  114. package/esm/relations/relation-loader.d.mts +15 -5
  115. package/esm/relations/relation-loader.d.mts.map +1 -1
  116. package/esm/relations/relation-loader.mjs +51 -16
  117. package/esm/relations/relation-loader.mjs.map +1 -1
  118. package/esm/remover/database-remover.d.mts.map +1 -1
  119. package/esm/remover/database-remover.mjs +21 -7
  120. package/esm/remover/database-remover.mjs.map +1 -1
  121. package/esm/restorer/database-restorer.d.mts +12 -2
  122. package/esm/restorer/database-restorer.d.mts.map +1 -1
  123. package/esm/restorer/database-restorer.mjs +43 -13
  124. package/esm/restorer/database-restorer.mjs.map +1 -1
  125. package/esm/sync/model-events.mjs +63 -3
  126. package/esm/sync/model-events.mjs.map +1 -1
  127. package/esm/sync/model-sync-operation.d.mts.map +1 -1
  128. package/esm/sync/model-sync-operation.mjs +3 -2
  129. package/esm/sync/model-sync-operation.mjs.map +1 -1
  130. package/esm/sync/sync-manager.d.mts +3 -42
  131. package/esm/sync/sync-manager.d.mts.map +1 -1
  132. package/esm/sync/sync-manager.mjs +37 -126
  133. package/esm/sync/sync-manager.mjs.map +1 -1
  134. package/esm/transactions/after-commit.d.mts +24 -0
  135. package/esm/transactions/after-commit.d.mts.map +1 -0
  136. package/esm/transactions/after-commit.mjs +47 -0
  137. package/esm/transactions/after-commit.mjs.map +1 -0
  138. package/esm/utils/connect-to-database.d.mts.map +1 -1
  139. package/esm/utils/connect-to-database.mjs +4 -4
  140. package/esm/utils/connect-to-database.mjs.map +1 -1
  141. package/esm/utils/database-writer.utils.d.mts +1 -1
  142. package/esm/utils/database-writer.utils.d.mts.map +1 -1
  143. package/esm/utils/database-writer.utils.mjs +1 -0
  144. package/esm/utils/database-writer.utils.mjs.map +1 -1
  145. package/esm/utils/define-model.d.mts.map +1 -1
  146. package/esm/utils/define-model.mjs +2 -10
  147. package/esm/utils/define-model.mjs.map +1 -1
  148. package/esm/utils/is-valid-date-value.mjs +3 -2
  149. package/esm/utils/is-valid-date-value.mjs.map +1 -1
  150. package/esm/utils/once-connected.d.mts.map +1 -1
  151. package/esm/utils/once-connected.mjs +12 -2
  152. package/esm/utils/once-connected.mjs.map +1 -1
  153. package/esm/validation/mutators/embed-mutator.mjs +19 -11
  154. package/esm/validation/mutators/embed-mutator.mjs.map +1 -1
  155. package/esm/validation/plugins/embed-validator-plugin.d.mts +2 -2
  156. package/esm/validation/plugins/embed-validator-plugin.d.mts.map +1 -1
  157. package/esm/validation/plugins/embed-validator-plugin.mjs +8 -2
  158. package/esm/validation/plugins/embed-validator-plugin.mjs.map +1 -1
  159. package/esm/validation/rules/database-model-rule.mjs +6 -5
  160. package/esm/validation/rules/database-model-rule.mjs.map +1 -1
  161. package/esm/validation/rules/exists-rule.mjs +6 -4
  162. package/esm/validation/rules/exists-rule.mjs.map +1 -1
  163. package/esm/validation/rules/unique-rule.mjs +5 -2
  164. package/esm/validation/rules/unique-rule.mjs.map +1 -1
  165. package/esm/validation/validators/embed-validator.d.mts.map +1 -1
  166. package/esm/validation/validators/embed-validator.mjs +1 -1
  167. package/esm/validation/validators/embed-validator.mjs.map +1 -1
  168. package/esm/writer/database-writer.d.mts +27 -0
  169. package/esm/writer/database-writer.d.mts.map +1 -1
  170. package/esm/writer/database-writer.mjs +84 -17
  171. package/esm/writer/database-writer.mjs.map +1 -1
  172. package/llms-full.txt +2783 -2766
  173. package/llms.txt +15 -15
  174. package/package.json +6 -6
  175. package/skills/aggregate-data/SKILL.md +165 -165
  176. package/skills/alter-migration/SKILL.md +531 -531
  177. package/skills/configure-delete-strategy/SKILL.md +130 -130
  178. package/skills/define-model/SKILL.md +205 -205
  179. package/skills/define-relations/SKILL.md +171 -171
  180. package/skills/index.md +23 -0
  181. package/skills/manage-data-sources/SKILL.md +140 -140
  182. package/skills/manage-transactions/SKILL.md +208 -191
  183. package/skills/paginate-results/SKILL.md +122 -122
  184. package/skills/perform-atomic-ops/SKILL.md +156 -156
  185. package/skills/query-data/SKILL.md +213 -213
  186. package/skills/run-cascade-cli/SKILL.md +125 -125
  187. package/skills/search-by-vector/SKILL.md +127 -127
  188. package/skills/subscribe-to-model-events/SKILL.md +148 -148
  189. package/skills/track-changes/SKILL.md +127 -127
  190. package/skills/write-migration/SKILL.md +206 -206
  191. package/esm/drivers/postgres/postgres-sync-adapter.d.mts +0 -79
  192. package/esm/drivers/postgres/postgres-sync-adapter.d.mts.map +0 -1
  193. package/esm/drivers/postgres/postgres-sync-adapter.mjs +0 -162
  194. package/esm/drivers/postgres/postgres-sync-adapter.mjs.map +0 -1
@@ -1,148 +1,148 @@
1
- ---
2
- name: subscribe-to-model-events
3
- description: "Use model events in @warlock.js/cascade; use when you need to subscribe to model events."
4
- ---
5
-
6
- # Use model events
7
-
8
- Every meaningful moment in a model's lifecycle — about to validate, about to save, just saved, just deleted — fires an event. Subscribe to hook in cross-cutting behavior without scattering it across every service.
9
-
10
- ## Subscribing — per-model
11
-
12
- ```ts
13
- User.on("saved", async (user) => {
14
- await searchIndex.upsert({ id: user.id, name: user.get("name") });
15
- });
16
-
17
- User.on("deleted", async (user) => {
18
- await searchIndex.remove(user.id);
19
- });
20
- ```
21
-
22
- Listeners are async-aware — the model's persistence awaits the listener before moving on. A listener that throws during a pre-write event (`saving` / `creating` / `updating` / `validating`) aborts the operation; the error propagates to the caller.
23
-
24
- ## The full event catalog
25
-
26
- The events fire in this order. The **gerund** form (`saving`, `creating`) is the "before" hook — there is no `beforeSave` alias.
27
-
28
- | Event | Fires |
29
- | --- | --- |
30
- | `saving` | Before validation, on every `save()` (both insert and update paths) |
31
- | `validating` / `validated` | Around schema validation |
32
- | `creating` | Before a new record is inserted (first save) |
33
- | `updating` | Before an existing record's update is written |
34
- | `saved` | After a successful write (any branch — insert or update) |
35
- | `created` | After a new record is inserted |
36
- | `updated` | After an existing record's update is written |
37
- | `deleting` / `deleted` | Before / after the delete strategy runs |
38
- | `restoring` / `restored` | Before / after a soft-deleted or trashed record is restored |
39
- | `fetching` / `fetched` / `hydrating` | Around reads / instance hydration |
40
-
41
- `saving` fires for both inserts and updates; `creating` / `updating` narrow to the branch. So `saved` always fires; exactly one of `created` / `updated` follows.
42
-
43
- ## Listener signature
44
-
45
- ```ts
46
- User.on("saving", async (user, context) => {
47
- // user: the User instance about to be persisted
48
- // context: { isInsert, options, mode } for `saving`; varies per event
49
- });
50
- ```
51
-
52
- The first argument is the model instance. The second carries event context (for `saving`: whether it's an insert, the `save()` options, and the mode).
53
-
54
- ## Throwing to abort
55
-
56
- A listener on a pre-write event that throws stops the lifecycle — the save / delete never completes and the error propagates:
57
-
58
- ```ts
59
- User.on("saving", async (user) => {
60
- if (user.isDirty("email") && (await emailIsBlacklisted(user.get("email")))) {
61
- throw new Error("Email domain is blacklisted");
62
- }
63
- });
64
- ```
65
-
66
- Pair with `isDirty()` from [`@warlock.js/cascade/track-changes/SKILL.md`](@warlock.js/cascade/track-changes/SKILL.md) so the listener only runs its expensive check when the relevant field changed.
67
-
68
- ## Global listeners — across all models
69
-
70
- For framework-level concerns (audit log, observability, cache invalidation), subscribe on the base `Model` — the listener runs for every model:
71
-
72
- ```ts
73
- import { Model } from "@warlock.js/cascade";
74
-
75
- Model.on("saved", async (instance) => {
76
- await audit.log("save", instance.constructor.name, instance.id, instance.getDirtyColumns());
77
- });
78
- ```
79
-
80
- Filter by `instance.constructor.name === "User"` when you only care about specific models, or register per-model handlers when the scope is narrow. (`Model.globalEvents()` returns the underlying global emitter if you need direct access.)
81
-
82
- ## `off()` to unsubscribe
83
-
84
- ```ts
85
- const handler = async (user) => {
86
- /* ... */
87
- };
88
- const unsubscribe = User.on("saved", handler);
89
-
90
- // later — either:
91
- unsubscribe();
92
- // or:
93
- User.off("saved", handler);
94
- ```
95
-
96
- `on()` also returns an unsubscribe function. Mostly useful in tests where you want to temporarily swap behavior.
97
-
98
- ## Common patterns
99
-
100
- ### Audit log (combined with dirty tracking)
101
-
102
- ```ts
103
- Model.on("updated", async (instance) => {
104
- const changes = instance.getDirtyColumnsWithValues();
105
- if (Object.keys(changes).length === 0) {
106
- return;
107
- }
108
-
109
- await AuditLog.create({
110
- model: instance.constructor.name,
111
- record_id: instance.id,
112
- changes,
113
- saved_at: new Date(),
114
- });
115
- });
116
- ```
117
-
118
- Use `updated` (not `saved`) when you only want change diffs — `getDirtyColumnsWithValues()` is still populated in the post-write events because the tracker resets *after* they fire.
119
-
120
- ### Cache invalidation
121
-
122
- ```ts
123
- User.on("saved", async (user) => {
124
- await cache.tags([`user.${user.id}`]).invalidate();
125
- });
126
-
127
- User.on("deleted", async (user) => {
128
- await cache.tags([`user.${user.id}`]).invalidate();
129
- });
130
- ```
131
-
132
- See [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md).
133
-
134
- ### Side effects that must only fire after commit
135
-
136
- For external side effects (queues, emails) that must only fire if a surrounding transaction commits, don't run them in the `saved` handler — write to an outbox table and dispatch from a worker after commit.
137
-
138
- ## Things NOT to do
139
-
140
- - Don't reach for `beforeSave` / `destroying` / `destroyed` — they don't exist. The hooks are `saving` (pre-save) and `deleting` / `deleted`.
141
- - Don't run long external work (HTTP calls, queue dispatches) directly in `saving` / `saved` handlers — inside a transaction they extend the lock. Use an outbox table.
142
- - Don't mutate the model's own fields in `saved` and expect them to persist. The write already happened; mutations here are in-memory only. Use `saving` for pre-persist mutation.
143
- - Don't subscribe inside a function that runs on every request. Register handlers once at startup, in a dedicated init file.
144
-
145
- ## See also
146
-
147
- - [`@warlock.js/cascade/track-changes/SKILL.md`](@warlock.js/cascade/track-changes/SKILL.md) — `isDirty` / `getDirtyColumnsWithValues` for "only run if this field changed"
148
- - [`@warlock.js/cascade/configure-delete-strategy/SKILL.md`](@warlock.js/cascade/configure-delete-strategy/SKILL.md) — `deleting` / `deleted` / `restored` around delete strategies
1
+ ---
2
+ name: subscribe-to-model-events
3
+ description: 'Hook into model lifecycle events — `saving` / `saved`, `creating` / `created`, `updating` / `updated`, `validating` / `validated`, `deleting` / `deleted`, `restoring` / `restored`, `fetching` / `fetched`. Per-model `Model.on(event, fn)` or global via `Model.globalEvents()`. Triggers: `Model.on`, `Model.off`, `saving`, `saved`, `created`, `updated`, `deleting`, `deleted`, `restored`; "audit log on save", "notify on change", "denormalize into search index"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: dirty tracking — `@warlock.js/cascade/track-changes/SKILL.md`; competing libs `mongoose` middleware, `typeorm` subscribers, `prisma` extensions.'
4
+ ---
5
+
6
+ # Use model events
7
+
8
+ Every meaningful moment in a model's lifecycle — about to validate, about to save, just saved, just deleted — fires an event. Subscribe to hook in cross-cutting behavior without scattering it across every service.
9
+
10
+ ## Subscribing — per-model
11
+
12
+ ```ts
13
+ User.on("saved", async (user) => {
14
+ await searchIndex.upsert({ id: user.id, name: user.get("name") });
15
+ });
16
+
17
+ User.on("deleted", async (user) => {
18
+ await searchIndex.remove(user.id);
19
+ });
20
+ ```
21
+
22
+ Listeners are async-aware — the model's persistence awaits the listener before moving on. A listener that throws during a pre-write event (`saving` / `creating` / `updating` / `validating`) aborts the operation; the error propagates to the caller.
23
+
24
+ ## The full event catalog
25
+
26
+ The events fire in this order. The **gerund** form (`saving`, `creating`) is the "before" hook — there is no `beforeSave` alias.
27
+
28
+ | Event | Fires |
29
+ | --- | --- |
30
+ | `saving` | Before validation, on every `save()` (both insert and update paths) |
31
+ | `validating` / `validated` | Around schema validation |
32
+ | `creating` | Before a new record is inserted (first save) |
33
+ | `updating` | Before an existing record's update is written |
34
+ | `saved` | After a successful write (any branch — insert or update) |
35
+ | `created` | After a new record is inserted |
36
+ | `updated` | After an existing record's update is written |
37
+ | `deleting` / `deleted` | Before / after the delete strategy runs |
38
+ | `restoring` / `restored` | Before / after a soft-deleted or trashed record is restored |
39
+ | `fetching` / `fetched` / `hydrating` | Around reads / instance hydration |
40
+
41
+ `saving` fires for both inserts and updates; `creating` / `updating` narrow to the branch. So `saved` always fires; exactly one of `created` / `updated` follows.
42
+
43
+ ## Listener signature
44
+
45
+ ```ts
46
+ User.on("saving", async (user, context) => {
47
+ // user: the User instance about to be persisted
48
+ // context: { isInsert, options, mode } for `saving`; varies per event
49
+ });
50
+ ```
51
+
52
+ The first argument is the model instance. The second carries event context (for `saving`: whether it's an insert, the `save()` options, and the mode).
53
+
54
+ ## Throwing to abort
55
+
56
+ A listener on a pre-write event that throws stops the lifecycle — the save / delete never completes and the error propagates:
57
+
58
+ ```ts
59
+ User.on("saving", async (user) => {
60
+ if (user.isDirty("email") && (await emailIsBlacklisted(user.get("email")))) {
61
+ throw new Error("Email domain is blacklisted");
62
+ }
63
+ });
64
+ ```
65
+
66
+ Pair with `isDirty()` from [`@warlock.js/cascade/track-changes/SKILL.md`](@warlock.js/cascade/track-changes/SKILL.md) so the listener only runs its expensive check when the relevant field changed.
67
+
68
+ ## Global listeners — across all models
69
+
70
+ For framework-level concerns (audit log, observability, cache invalidation), subscribe on the base `Model` — the listener runs for every model:
71
+
72
+ ```ts
73
+ import { Model } from "@warlock.js/cascade";
74
+
75
+ Model.on("saved", async (instance) => {
76
+ await audit.log("save", instance.constructor.name, instance.id, instance.getDirtyColumns());
77
+ });
78
+ ```
79
+
80
+ Filter by `instance.constructor.name === "User"` when you only care about specific models, or register per-model handlers when the scope is narrow. (`Model.globalEvents()` returns the underlying global emitter if you need direct access.)
81
+
82
+ ## `off()` to unsubscribe
83
+
84
+ ```ts
85
+ const handler = async (user) => {
86
+ /* ... */
87
+ };
88
+ const unsubscribe = User.on("saved", handler);
89
+
90
+ // later — either:
91
+ unsubscribe();
92
+ // or:
93
+ User.off("saved", handler);
94
+ ```
95
+
96
+ `on()` also returns an unsubscribe function. Mostly useful in tests where you want to temporarily swap behavior.
97
+
98
+ ## Common patterns
99
+
100
+ ### Audit log (combined with dirty tracking)
101
+
102
+ ```ts
103
+ Model.on("updated", async (instance) => {
104
+ const changes = instance.getDirtyColumnsWithValues();
105
+ if (Object.keys(changes).length === 0) {
106
+ return;
107
+ }
108
+
109
+ await AuditLog.create({
110
+ model: instance.constructor.name,
111
+ record_id: instance.id,
112
+ changes,
113
+ saved_at: new Date(),
114
+ });
115
+ });
116
+ ```
117
+
118
+ Use `updated` (not `saved`) when you only want change diffs — `getDirtyColumnsWithValues()` is still populated in the post-write events because the tracker resets *after* they fire.
119
+
120
+ ### Cache invalidation
121
+
122
+ ```ts
123
+ User.on("saved", async (user) => {
124
+ await cache.tags([`user.${user.id}`]).invalidate();
125
+ });
126
+
127
+ User.on("deleted", async (user) => {
128
+ await cache.tags([`user.${user.id}`]).invalidate();
129
+ });
130
+ ```
131
+
132
+ See [`@warlock.js/cache/use-cache-tags/SKILL.md`](@warlock.js/cache/use-cache-tags/SKILL.md).
133
+
134
+ ### Side effects that must only fire after commit
135
+
136
+ For external side effects (queues, emails) that must only fire if a surrounding transaction commits, don't run them in the `saved` handler — write to an outbox table and dispatch from a worker after commit.
137
+
138
+ ## Things NOT to do
139
+
140
+ - Don't reach for `beforeSave` / `destroying` / `destroyed` — they don't exist. The hooks are `saving` (pre-save) and `deleting` / `deleted`.
141
+ - Don't run long external work (HTTP calls, queue dispatches) directly in `saving` / `saved` handlers — inside a transaction they extend the lock. Use an outbox table.
142
+ - Don't mutate the model's own fields in `saved` and expect them to persist. The write already happened; mutations here are in-memory only. Use `saving` for pre-persist mutation.
143
+ - Don't subscribe inside a function that runs on every request. Register handlers once at startup, in a dedicated init file.
144
+
145
+ ## See also
146
+
147
+ - [`@warlock.js/cascade/track-changes/SKILL.md`](@warlock.js/cascade/track-changes/SKILL.md) — `isDirty` / `getDirtyColumnsWithValues` for "only run if this field changed"
148
+ - [`@warlock.js/cascade/configure-delete-strategy/SKILL.md`](@warlock.js/cascade/configure-delete-strategy/SKILL.md) — `deleting` / `deleted` / `restored` around delete strategies
@@ -1,127 +1,127 @@
1
- ---
2
- name: track-changes
3
- description: "Track changes in @warlock.js/cascade; use when you need to track changes."
4
- ---
5
-
6
- # Track changes
7
-
8
- Every Cascade model carries a dirty tracker that records which fields you've changed since the model was loaded (or since the last `save()`). The tracker is the source of truth for "what would change if I save now."
9
-
10
- ## The main reads
11
-
12
- ```ts
13
- user.hasChanges(); // boolean — any field changed (or removed)?
14
- user.isDirty("email"); // boolean — specifically this column?
15
-
16
- user.getDirtyColumns(); // string[] — names of changed columns
17
- user.getRemovedColumns(); // string[] — columns explicitly unset since load
18
-
19
- user.getDirtyColumnsWithValues();
20
- // Record<string, { oldValue, newValue }> — the full diff, old + new per column
21
- ```
22
-
23
- `isDirty` takes **one** column. To check several, ask `getDirtyColumns()` once and test membership (see the multi-field section below).
24
-
25
- ## Conditional logic before save
26
-
27
- ```ts
28
- if (user.isDirty("email")) {
29
- const { oldValue } = user.getDirtyColumnsWithValues().email;
30
- await mailer.sendEmailChangeNotice(oldValue);
31
- }
32
-
33
- await user.save();
34
- ```
35
-
36
- The classic shape — only fire the side effect if the field actually changed. The pre-mutation value comes from `getDirtyColumnsWithValues()[column].oldValue`.
37
-
38
- ## Diff for an audit log
39
-
40
- ```ts
41
- const changes = user.getDirtyColumnsWithValues();
42
- // e.g. { name: { oldValue: "Ada", newValue: "Augusta Ada King" } }
43
-
44
- await AuditLog.create({
45
- user_id: user.id,
46
- before: Object.fromEntries(
47
- Object.entries(changes).map(([field, { oldValue }]) => [field, oldValue]),
48
- ),
49
- after: Object.fromEntries(
50
- Object.entries(changes).map(([field, { newValue }]) => [field, newValue]),
51
- ),
52
- changed_at: new Date(),
53
- });
54
- ```
55
-
56
- `getDirtyColumnsWithValues()` hands you old and new together, so you never need a second call to read the prior value.
57
-
58
- ## After save — tracker resets
59
-
60
- ```ts
61
- user.set("name", "Augusta");
62
- user.isDirty("name"); // true
63
- await user.save();
64
- user.isDirty("name"); // false — tracker reset to the just-saved state
65
- ```
66
-
67
- `save()` resets the tracker's baseline to the persisted state. Subsequent `isDirty()` checks measure changes since the last `save()`, not since the original load.
68
-
69
- ## Multi-field check
70
-
71
- `isDirty` is single-column. For "did any of these change?", read the dirty set once:
72
-
73
- ```ts
74
- const dirty = new Set(user.getDirtyColumns());
75
- const billingTouched = ["billing_address", "billing_city", "billing_zip"].some((field) =>
76
- dirty.has(field),
77
- );
78
-
79
- if (billingTouched) {
80
- await revalidateBillingAddress(user);
81
- }
82
- ```
83
-
84
- ## Tracker vs `.get()`
85
-
86
- - `user.get("email")` — the **current** value (post-mutation if you called `.set` / `.merge`)
87
- - `user.getDirtyColumnsWithValues().email?.oldValue` — the **pre-mutation** value (whatever the DB had)
88
- - `user.getDirtyColumns()` — the **changed field names** (string array)
89
- - `user.getDirtyColumnsWithValues()` — the **full diff** (old + new per changed field)
90
-
91
- If you need both old and new together:
92
-
93
- ```ts
94
- const dirty = user.getDirtyColumnsWithValues();
95
- for (const [field, { oldValue, newValue }] of Object.entries(dirty)) {
96
- // ... log, validate, conditionally re-route
97
- }
98
- ```
99
-
100
- ## How a merge decides what changed
101
-
102
- `.merge()` deep-merges **plain objects only**. Every other value — primitives, arrays, and class instances such as `Date`, `Map`, `Set`, `RegExp` — **replaces** the target outright.
103
-
104
- ```ts
105
- // plain object → merged key-by-key, siblings survive
106
- user.merge({ profile: { age: 26 } }); // profile.city untouched
107
- user.getDirtyColumns(); // ["profile.age"]
108
-
109
- // Date → replaced, not merged
110
- conversation.merge({ lastOutboundAt: new Date() });
111
- conversation.getDirtyColumns(); // ["lastOutboundAt"]
112
- ```
113
-
114
- :::note[Fixed in 4.9.1]
115
- Before 4.9.1 a `Date` merged over a column that **already held a `Date`** was silently dropped: the merge treated it as a mergeable container and recursed into it, and a `Date` has no own enumerable properties, so nothing was copied. The column never went dirty and `save()` returned `{ success: true, modifiedCount: 0 }` with no `UPDATE` issued. Writing into an empty column always worked, so only overwrites were affected. If you are on 4.9.0 or earlier, upgrade.
116
- :::
117
-
118
- ## Things NOT to do
119
-
120
- - Don't use `hasChanges()` / `isDirty()` after `save()` to verify the save persisted. The tracker resets to clean on save — these become false regardless. Read back from the DB if you need verification.
121
- - Don't compare `user.get(field) === oldValue` for change detection — call `isDirty(field)` instead. The tracker uses identity-aware comparison appropriate to the column type (deep-equal for objects/arrays).
122
- - Don't expect dirty tracking on relations. The tracker covers columns only. For relation changes, use lifecycle events or compare counts.
123
-
124
- ## See also
125
-
126
- - [`@warlock.js/cascade/subscribe-to-model-events/SKILL.md`](@warlock.js/cascade/subscribe-to-model-events/SKILL.md) — combining with `saved` / `saving` for cross-cutting behavior
127
- - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — the `.set` / `.merge` / `.save` idioms that stage changes
1
+ ---
2
+ name: track-changes
3
+ description: 'Inspect a model''s pending changes — `hasChanges()` (any field dirty?), `isDirty(column)` (one column), `getDirtyColumns()` (changed field names), `getDirtyColumnsWithValues()` (old + new per field), `getRemovedColumns()` (unset fields). Triggers: `hasChanges`, `isDirty`, `getDirtyColumns`, `getDirtyColumnsWithValues`, `getRemovedColumns`; "only run if email changed", "diff for an audit log", "what fields are dirty", "compare old vs new value"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: hooking into save — `@warlock.js/cascade/subscribe-to-model-events/SKILL.md`; update idioms — `@warlock.js/cascade/define-model/SKILL.md`; competing libs `mongoose` `isModified` / `modifiedPaths`, `typeorm` change detection.'
4
+ ---
5
+
6
+ # Track changes
7
+
8
+ Every Cascade model carries a dirty tracker that records which fields you've changed since the model was loaded (or since the last `save()`). The tracker is the source of truth for "what would change if I save now."
9
+
10
+ ## The main reads
11
+
12
+ ```ts
13
+ user.hasChanges(); // boolean — any field changed (or removed)?
14
+ user.isDirty("email"); // boolean — specifically this column?
15
+
16
+ user.getDirtyColumns(); // string[] — names of changed columns
17
+ user.getRemovedColumns(); // string[] — columns explicitly unset since load
18
+
19
+ user.getDirtyColumnsWithValues();
20
+ // Record<string, { oldValue, newValue }> — the full diff, old + new per column
21
+ ```
22
+
23
+ `isDirty` takes **one** column. To check several, ask `getDirtyColumns()` once and test membership (see the multi-field section below).
24
+
25
+ ## Conditional logic before save
26
+
27
+ ```ts
28
+ if (user.isDirty("email")) {
29
+ const { oldValue } = user.getDirtyColumnsWithValues().email;
30
+ await mailer.sendEmailChangeNotice(oldValue);
31
+ }
32
+
33
+ await user.save();
34
+ ```
35
+
36
+ The classic shape — only fire the side effect if the field actually changed. The pre-mutation value comes from `getDirtyColumnsWithValues()[column].oldValue`.
37
+
38
+ ## Diff for an audit log
39
+
40
+ ```ts
41
+ const changes = user.getDirtyColumnsWithValues();
42
+ // e.g. { name: { oldValue: "Ada", newValue: "Augusta Ada King" } }
43
+
44
+ await AuditLog.create({
45
+ user_id: user.id,
46
+ before: Object.fromEntries(
47
+ Object.entries(changes).map(([field, { oldValue }]) => [field, oldValue]),
48
+ ),
49
+ after: Object.fromEntries(
50
+ Object.entries(changes).map(([field, { newValue }]) => [field, newValue]),
51
+ ),
52
+ changed_at: new Date(),
53
+ });
54
+ ```
55
+
56
+ `getDirtyColumnsWithValues()` hands you old and new together, so you never need a second call to read the prior value.
57
+
58
+ ## After save — tracker resets
59
+
60
+ ```ts
61
+ user.set("name", "Augusta");
62
+ user.isDirty("name"); // true
63
+ await user.save();
64
+ user.isDirty("name"); // false — tracker reset to the just-saved state
65
+ ```
66
+
67
+ `save()` resets the tracker's baseline to the persisted state. Subsequent `isDirty()` checks measure changes since the last `save()`, not since the original load.
68
+
69
+ ## Multi-field check
70
+
71
+ `isDirty` is single-column. For "did any of these change?", read the dirty set once:
72
+
73
+ ```ts
74
+ const dirty = new Set(user.getDirtyColumns());
75
+ const billingTouched = ["billing_address", "billing_city", "billing_zip"].some((field) =>
76
+ dirty.has(field),
77
+ );
78
+
79
+ if (billingTouched) {
80
+ await revalidateBillingAddress(user);
81
+ }
82
+ ```
83
+
84
+ ## Tracker vs `.get()`
85
+
86
+ - `user.get("email")` — the **current** value (post-mutation if you called `.set` / `.merge`)
87
+ - `user.getDirtyColumnsWithValues().email?.oldValue` — the **pre-mutation** value (whatever the DB had)
88
+ - `user.getDirtyColumns()` — the **changed field names** (string array)
89
+ - `user.getDirtyColumnsWithValues()` — the **full diff** (old + new per changed field)
90
+
91
+ If you need both old and new together:
92
+
93
+ ```ts
94
+ const dirty = user.getDirtyColumnsWithValues();
95
+ for (const [field, { oldValue, newValue }] of Object.entries(dirty)) {
96
+ // ... log, validate, conditionally re-route
97
+ }
98
+ ```
99
+
100
+ ## How a merge decides what changed
101
+
102
+ `.merge()` deep-merges **plain objects only**. Every other value — primitives, arrays, and class instances such as `Date`, `Map`, `Set`, `RegExp` — **replaces** the target outright.
103
+
104
+ ```ts
105
+ // plain object → merged key-by-key, siblings survive
106
+ user.merge({ profile: { age: 26 } }); // profile.city untouched
107
+ user.getDirtyColumns(); // ["profile.age"]
108
+
109
+ // Date → replaced, not merged
110
+ conversation.merge({ lastOutboundAt: new Date() });
111
+ conversation.getDirtyColumns(); // ["lastOutboundAt"]
112
+ ```
113
+
114
+ :::note[Fixed in 4.9.1]
115
+ Before 4.9.1 a `Date` merged over a column that **already held a `Date`** was silently dropped: the merge treated it as a mergeable container and recursed into it, and a `Date` has no own enumerable properties, so nothing was copied. The column never went dirty and `save()` returned `{ success: true, modifiedCount: 0 }` with no `UPDATE` issued. Writing into an empty column always worked, so only overwrites were affected. If you are on 4.9.0 or earlier, upgrade.
116
+ :::
117
+
118
+ ## Things NOT to do
119
+
120
+ - Don't use `hasChanges()` / `isDirty()` after `save()` to verify the save persisted. The tracker resets to clean on save — these become false regardless. Read back from the DB if you need verification.
121
+ - Don't compare `user.get(field) === oldValue` for change detection — call `isDirty(field)` instead. The tracker uses identity-aware comparison appropriate to the column type (deep-equal for objects/arrays).
122
+ - Don't expect dirty tracking on relations. The tracker covers columns only. For relation changes, use lifecycle events or compare counts.
123
+
124
+ ## See also
125
+
126
+ - [`@warlock.js/cascade/subscribe-to-model-events/SKILL.md`](@warlock.js/cascade/subscribe-to-model-events/SKILL.md) — combining with `saved` / `saving` for cross-cutting behavior
127
+ - [`@warlock.js/cascade/define-model/SKILL.md`](@warlock.js/cascade/define-model/SKILL.md) — the `.set` / `.merge` / `.save` idioms that stage changes