bunsane 0.3.1 → 0.4.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 (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
package/CHANGELOG.md CHANGED
@@ -1,318 +1,445 @@
1
- # Changelog
2
-
3
- All notable changes to bunsane are documented here.
4
-
5
- ## Unreleased
6
-
7
- ### Added (HR-Screening ticket batch — BUNSANE-002..006)
8
-
9
- - **`@ScheduledTask` allows entity-less time-based tasks.** Previously
10
- `SchedulerManager.registerTask` rejected tasks without `query` or
11
- `componentTarget`, contradicting documented "runs every hour" examples.
12
- Time-based tasks now register successfully and invoke the handler with
13
- no entity argument on each tick. Existing entity-targeted tasks
14
- unchanged. Ticket BUNSANE-002.
15
-
16
- - **`Entity.requireComponents(ctors)` hydrator.** Batched-load helper
17
- that ensures the given component constructors are present on the
18
- in-memory `componentList`. Required before `set` / `save` flows that
19
- may trigger `@ComponentTargetHook` — hook matching reads
20
- `componentList()` (in-memory only), so tag components must be loaded
21
- first for the hook to fire. Ticket BUNSANE-003.
22
-
23
- - **`ServiceRegistry` class named-exported.** `service/ServiceRegistry.ts`
24
- now exports the class as named alongside the existing default-instance
25
- export. Available via `service/index.ts` as `ServiceRegistryClass` for
26
- type/subclass use; existing `ServiceRegistry` import remains the
27
- singleton instance for backward compatibility. Ticket BUNSANE-004.
28
-
29
- - **`CacheManager.invalidateEntities(ids: string[])`.** Batched helper
30
- that invalidates both the entity-existence cache and all component
31
- caches for a list of IDs. Call after a raw-SQL write (`db.unsafe`)
32
- that bypasses `Entity.set` / `Entity.save`. Ticket BUNSANE-005.
33
-
34
- - **`Entity.reload(opts?)` refresher.** Discards in-memory component
35
- state and re-hydrates from the `components` table. Preserves entity
36
- identity callers holding a reference see fresh data on the same
37
- instance. Use after raw-SQL writes or when a sibling `Entity`
38
- instance with the same id mutated persisted data. Ticket BUNSANE-006.
39
-
40
- - **Empty-string filter values supported.** `Query.filter(field, op, '')`
41
- and the downstream SQL emit path (`ComponentInclusionNode`,
42
- `PreparedStatementCache.execute`, `Query.doExec` / `doCount` /
43
- `doAggregate` param validators) previously rejected empty /
44
- whitespace-only values with "would cause PostgreSQL UUID parsing errors".
45
- JSONB text extraction (`c.data->>'field'`) returns text, so `= ''` /
46
- `!= ''` / `LIKE ''` are legitimate for text fields. The UUID-cast path
47
- is gated by a value-side regex that an empty string cannot match, so
48
- unsafe casts never fire. `findById('')` still throws — entity IDs
49
- remain UUID-typed.
50
-
51
- - **`Entity.drainPendingSideEffects(timeoutMs)`.** Drainable tracking
52
- for post-commit work scheduled via `queueMicrotask` from `save()`
53
- (cache invalidation + lifecycle hooks). Wired into `App.shutdown`
54
- after `drainPendingCacheOps`. Tests under PGlite can call this in
55
- `beforeAll` to settle prior-file background work before asserting.
56
- Partial mitigation for BUNSANE-001 (Bun SQL / PGlite visibility race
57
- — see `CLAUDE.md` PGlite section for full context).
58
-
59
- ### Fixed (PR E outbox, cache, query hardening)
60
-
61
- - **OutboxWorker publishes to Redis concurrently and marks rows in bulk.**
62
- Previously `processBatch` awaited each `publisher.xadd` serially inside
63
- the PG transaction, holding `FOR UPDATE` row locks for up to N ×
64
- `commandTimeout` when Redis was slow. Now uses `Promise.allSettled` to
65
- publish the whole batch in parallel — worst-case lock hold drops to a
66
- single xadd timeout. Followed by a single bulk `UPDATE … WHERE id IN
67
- …` instead of N serial updates. Tickets H-DB-1 (partial — full fix
68
- needs claim-via-column redesign so Redis latency is outside the PG
69
- transaction entirely) and H-DB-3.
70
-
71
- - **`Entity.save` pre-flights `ComponentRegistry.getReadyPromise` outside
72
- the transaction.** Previously `doSave` awaited registry readiness from
73
- inside `executeSave`, so a slow DDL (partition creation) would keep a PG
74
- transaction idle. Pre-flight loop in `save()` awaits readiness before
75
- opening the transaction; `doSave` now only asserts readiness and throws
76
- if a caller bypassed `save()`. Ticket H-DB-4.
77
-
78
- - **Entity.set / Entity.remove fire-and-forget cache ops now drainable on
79
- shutdown.** Previously `setImmediate(async () => { … })` was untracked,
80
- so SIGTERM could abandon in-flight cache writes. `Entity.pendingCacheOps`
81
- is a drainable `Set<Promise<void>>`, and `Entity.drainPendingCacheOps`
82
- is awaited by `App.shutdown` between HTTP drain and cache disconnect.
83
- Ticket H-CACHE-1.
84
-
85
- - **`CacheManager.shutdownProvider` descends into `MultiLevelCache` layers.**
86
- Previously only checked the top-level provider for `disconnect` /
87
- `stopCleanup` methods, so a MultiLevelCache deployment left its inner
88
- MemoryCache cleanup timer and Redis connection alive forever. Now
89
- dispatches to `getL1Cache()` and `getL2Cache()` when available. Ticket
90
- H-CACHE-2.
91
-
92
- - **`setComponentWriteThrough` preserves `createdAt` across updates.**
93
- Previously every write-through stamped `createdAt: new Date()`,
94
- corrupting the timeline across consecutive updates. Now peeks the
95
- existing cache entry and preserves its `createdAt` when present; only
96
- `updatedAt` is stamped fresh. Full fix (BaseComponent tracking
97
- timestamps natively) deferred. Ticket H-CACHE-3.
98
-
99
- - **Default query limit applied when `.take()` is omitted.** `Query.exec()`
100
- now applies a framework-level default LIMIT
101
- (env `BUNSANE_DEFAULT_QUERY_LIMIT`, default 10000, 0 to disable) and
102
- emits a warning so runaway queries are visible. Ticket H-QUERY-1.
103
-
104
- - **OrNode debug `console.log` traces removed from the production path.**
105
- Ticket H-QUERY-2.
106
-
107
- - **`unregisterDecoratedHooks` now actually unregisters.** Previously a
108
- no-op stub that warned to stderr. Hook IDs returned from each
109
- registration are stored in a `WeakMap<instance, string[]>` and passed
110
- to `EntityHookManager.removeHook` on tear-down. Enables per-instance
111
- cleanup in tests and service destruction. Ticket H-HOOK-3.
112
-
113
- ### Fixed (PR D scheduler + hook concurrency hardening)
114
-
115
- - **Entity.add / Entity.set / Entity.remove hook calls no longer leak
116
- unhandled rejections.** `EntityHookManager.executeHooks` is async, but
117
- the three mutating methods previously invoked it without `await` and the
118
- surrounding `try/catch` captured only synchronous throws. A hook
119
- declared `async` that rejected escaped as an unhandled rejection. `set`
120
- now `await`s consistently; `add` and `remove` remain synchronous (to
121
- preserve their fluent-chain / boolean signatures) and attach a
122
- `.catch` to the returned promise so rejections are logged rather than
123
- escaping. Ticket H-HOOK-1.
124
-
125
- - **Hook timeout timers no longer leak and late rejections no longer
126
- escape.** All four timeout race sites in `EntityHookManager` (sync path,
127
- async-parallel path, sync-batch path, async-batch path) now capture the
128
- `setTimeout` handle and `clearTimeout` on normal completion, and
129
- attach a detached `.catch` to the hook callback promise so a rejection
130
- that arrives after the race has been decided is logged rather than
131
- emitted as an unhandled rejection. Tickets H-HOOK-2 / H-MEM-2.
132
-
133
- - **SchedulerManager task interval no longer burns lock attempts for a
134
- still-running task.** `doExecuteTask` now skips early if
135
- `taskInfo.isRunning` is true, avoiding a wasted PG advisory-lock
136
- round-trip every tick when execution outlasts the interval. Increments
137
- `skippedExecutions`. Ticket H-SCHED-1.
138
-
139
- - **Scheduled-task retry timer is now tracked and cleared on stop.**
140
- `handleTaskFailure` previously scheduled retries with a bare
141
- `setTimeout` whose handle was never stored, so `stop()` could not
142
- clear it and the retry fired post-shutdown against a closed DB pool.
143
- The retry handle is now registered in `intervals` under
144
- `<taskId>:retry:<n>` and self-deletes once fired. The retry callback
145
- also checks `isRunning` before executing. Tickets H-SCHED-2 /
146
- H-SCHED-3.
147
-
148
- - **DistributedLock re-entry now reports overlap instead of success.**
149
- `tryAcquire` previously returned `acquired: true` when the instance
150
- already held the lock for `taskId`, which meant retry + interval could
151
- both enter `executeTask` concurrently. Now returns
152
- `acquired: false` so the second caller skips — defense-in-depth on
153
- top of the caller-side `isRunning` guard. Ticket H-SCHED-4.
154
-
155
- - **`executeWithTimeout` no longer leaks late rejections.** A scheduled
156
- task that rejects after its wrapper timed out previously produced an
157
- unhandled rejection (the wrapper was already settled). The wrapper now
158
- uses a `settled` flag and logs late rejections instead of propagating.
159
- Ticket H-SCHED-5.
160
-
161
- - **DistributedLock `reservePromise` nulls on reject.** Previously, if
162
- `db.reserve()` rejected (pool exhausted, shutdown mid-call), the
163
- rejected promise was cached in `reservePromise` forever and every
164
- subsequent `ensureReserved` received the same rejection. Now nulls the
165
- promise in the reject handler so future callers retry a fresh reserve.
166
- Ticket H-DB-2.
167
-
168
- - **`App.waitForAppReady` no longer polls indefinitely.** Replaced the
169
- 100ms `setInterval` with a one-shot phase listener and default 60s
170
- timeout. A boot failure that never reaches `APPLICATION_READY` now
171
- surfaces as a rejection instead of leaking a timer for process
172
- lifetime. Ticket H-MEM-1.
173
-
174
- ### Security
175
-
176
- - **SQL injection hardening across Query layer.** Identifiers (component
177
- table names, JSON field paths, ORDER BY properties, text-search language)
178
- interpolated into SQL via `db.unsafe(...)` or template literals are now
179
- validated against strict allow-lists before use. Added `query/SqlIdentifier.ts`
180
- with `assertIdentifier`, `assertComponentTableName`, `assertFieldPath`,
181
- `assertTsLanguage`. Applied at `Query.estimatedCount`, `Query.doAggregate`,
182
- `ComponentInclusionNode` sort expressions (3 sites), and
183
- `FullTextSearchBuilder` (3 sites + factory). Throws `InvalidIdentifierError`
184
- on unsafe input. Ticket C08.
185
-
186
- - **GraphQL depth limit hard minimum enforced.** Previously `maxDepth: 0`
187
- or `undefined` silently disabled the depth-limit guard, allowing CPU/memory
188
- DoS via deeply nested queries. Now `createYogaInstance` enforces a hard
189
- floor of 15 regardless of input; callers can raise but cannot disable.
190
- Ticket C06.
191
-
192
- - **Request AbortSignal now propagates into Yoga and REST handlers.** The
193
- 30s wall-clock timer previously only logged a warning; the signal was
194
- never forwarded downstream. Request timeouts (and client disconnects) now
195
- cancel in-flight resolvers, DB queries, and external calls. Uses
196
- `AbortSignal.any` (Bun/Node 20+) with a manual combiner fallback.
197
- Ticket C05.
198
-
199
- ### Fixed
200
-
201
- - **Sync lifecycle hooks now awaited, preventing unhandled rejections.**
202
- `EntityHookManager.executeHooks` previously discarded the return value of
203
- `hook.callback(event)` on the sync path when no timeout was configured.
204
- A hook mistakenly declared `async: false` but implemented as an
205
- `async function` would silently throw unhandled rejections, crashing the
206
- process under strict mode. Sync path now awaits consistently. Ticket C13.
207
-
208
- - **`createRequestContextPlugin` auto-applied by default.** Previously
209
- opt-in (and the export was commented out of the root barrel), so any app
210
- using `@BelongsTo` / `@HasMany` relations silently fell into N+1 query
211
- patterns. `App` now prepends the plugin to Yoga plugins by default. Opt
212
- out via `App.disableRequestContextPlugin()` if supplying your own
213
- DataLoader layer. Ticket C07.
214
-
215
- - **Redis cache no longer causes unbounded heap growth when Redis is
216
- unreachable.** `enableOfflineQueue` now defaults to `false` so commands
217
- fail fast and the caller's `try/catch` treats it as a cache miss instead
218
- of queuing indefinitely. Can be overridden per-deployment via
219
- `REDIS_ENABLE_OFFLINE_QUEUE=true` when you accept the memory risk.
220
- Ticket C02.
221
-
222
- - **Redis reconnect storm capped.** `retryStrategy` now returns `null`
223
- after `maxReconnectAttempts` (default 20) so a permanently unreachable
224
- Redis cannot spin forever, saturating logs and keeping the ioredis
225
- state machine busy. Configurable via `REDIS_MAX_RECONNECT_ATTEMPTS`.
226
- Default inter-attempt delay also raised from `times * 50` to
227
- `times * 200` (capped at 2s) for a gentler back-off. Ticket C03.
228
-
229
- - **`App.init()` now awaits `CacheManager.initialize()`.** Previously only
230
- `getInstance()` was called so pub/sub cross-instance invalidation was
231
- never set up and any app-supplied cache config was silently ignored.
232
- Added `App.setCacheConfig(config)` so callers can supply a partial
233
- config that is merged with `defaultCacheConfig` and passed to
234
- `initialize()`. Ticket C04.
235
-
236
- - **`Entity.doDelete` no longer leaks `idle in transaction` backends on timeout.**
237
- Same AbortController + in-flight query cancellation pattern as `Entity.save`.
238
- Post-commit cache invalidation and lifecycle hooks moved out of the save
239
- budget via `queueMicrotask`. Ticket C01.
240
-
241
- - **`SYSTEM_READY` phase errors are no longer swallowed silently.**
242
- Previously a schema-build, REST-registration, or scheduler-init failure was
243
- caught and only logged, leaving the app stuck at `isReady=false` with
244
- `/health/ready` returning 503 forever and k8s rollouts blocked indefinitely.
245
- Now marks the app unready, logs at fatal level, and exits so the orchestrator
246
- can restart. In tests, rethrows instead of exiting. Ticket C09.
247
-
248
- - **HTTP server drain is now awaited before tearing down dependencies.**
249
- `server.stop(false)` previously initiated drain but was not awaited, so the
250
- scheduler / cache / DB pool closed while requests were still executing,
251
- causing cascade failures in the final seconds of shutdown. Shutdown now
252
- polls pending requests (bounded by `shutdownGracePeriod`) before force-close,
253
- then stops each subsystem in order. Ticket C10.
254
-
255
- - **ApplicationLifecycle phase listeners are now captured and removed on
256
- shutdown.** Five singletons (`App`, `EntityManager`, `EntityHookManager`,
257
- `SchedulerManager`, `ServiceRegistry`) previously registered listeners
258
- without storing refs, so each `init()` call (common in tests) stacked
259
- listeners on the singleton `EventTarget`, permanently leaking memory and
260
- firing duplicate phase handlers. Each now captures the listener reference
261
- and exposes a `dispose()` / `disposeLifecycleIntegration()` method called
262
- from `App.shutdown()`. `init()` paths are also idempotent. Ticket C11.
263
-
264
- - **`ApplicationLifecycle.waitForPhase` replaced 100ms busy-loop with a
265
- listener-based Promise.** Previously a `while (currentPhase !== phase)`
266
- loop polling every 100ms; if the target phase was never reached (see
267
- SYSTEM_READY fix above) every caller hung forever. Now attaches a one-shot
268
- phase listener + `timeoutMs` (default 30s). Rejects with a descriptive
269
- error on timeout. Ticket C12.
270
-
271
- - **`SchedulerManager.stop()` now awaits in-flight tasks before returning.**
272
- Previously cleared timers and returned immediately; any task mid-execution
273
- continued running against a DB pool that was about to close in
274
- `App.shutdown()`. Now tracks each `executeTask` promise in a Set, and
275
- `stop(drainTimeoutMs = 15_000)` awaits `Promise.allSettled` bounded by the
276
- timeout. Scheduler listener also disposed. Ticket C14.
277
-
278
- - **Process-level error handlers (`unhandledRejection`, `uncaughtException`)
279
- and signal handlers (`SIGTERM`, `SIGINT`) now registered at the top of
280
- `App.init()` instead of only in `start()`.** Previously any rejection
281
- during boot (DB prep, component registration, cache init) was silently
282
- discarded by the runtime. Signal handlers now use `process.once` so a
283
- double SIGTERM cannot fire two concurrent shutdown paths. Ticket C15.
284
-
285
- - **`Entity.save` no longer leaks `idle in transaction` backends on timeout.**
286
- The previous implementation wrapped `db.transaction(...)` in a JS `setTimeout`
287
- and rejected the outer promise when the timer fired, but the underlying Bun
288
- SQL transaction continued on the server with no `COMMIT`/`ROLLBACK` ever
289
- sent. Under pgbouncer `transaction` pool mode this pinned backend sessions
290
- permanently, exhausting the pool and cascading into further save timeouts.
291
-
292
- `Entity.save` now threads an `AbortSignal` into `doSave`. When the wall-clock
293
- timer fires the signal is aborted, the in-flight `SQL.Query` is cancelled
294
- via `.cancel()`, and the cancellation propagates out of the transaction
295
- callback, triggering Bun SQL's automatic `ROLLBACK` and releasing the
296
- pooled connection. The `DB_STATEMENT_TIMEOUT` env var (already supported
297
- in `database/index.ts`) acts as a PostgreSQL-side backstop.
298
-
299
- See `docs` / handoff dated 2026-04-18 for incident details.
300
-
301
- ### Changed
302
-
303
- - **Post-commit side effects (cache invalidation, lifecycle hooks) no longer
304
- block `Entity.save`.** `handleCacheAfterSave` and `EntityHookManager.executeHooks`
305
- are now queued via `queueMicrotask` after the transaction commits. Save
306
- resolves as soon as the DB write is durable; cache or hook latency cannot
307
- consume the save budget or surface as save failures. Errors are logged
308
- and swallowed (matching prior error-handling behavior).
309
-
310
- ### Added
311
-
312
- - **`DB_SAVE_PROFILE=true` env var** — when set, `Entity.save` logs per-phase
313
- timings (`db`, `cache`, `hooks`, `total`) at info level. Off by default.
314
-
315
- - **Integration tests** in `tests/integration/entity/Entity.saveTimeout.test.ts`
316
- covering: aborted save leaves no partial rows, pool stays healthy after
317
- repeated aborts, backwards-compatible signal-less `doSave`, non-blocking
318
- post-commit work.
1
+ # Changelog
2
+
3
+ All notable changes to bunsane are documented here.
4
+
5
+ ## 0.4.0 — 2026-06-11
6
+
7
+ ### Performance (2026-06-10 overhaul)
8
+
9
+ - **ALS request scope** (`core/requestScope.ts`) bare `entity.get()` calls inside
10
+ `@ArcheTypeFunction`, `Unwrap`, and `populateRelations` are now batched
11
+ automatically per request.
12
+ - **Sort-driven scan** for multi-component `sortBy` queries LIMIT pushdown into
13
+ the sort component scan (excluded for OR filters and cursor pagination).
14
+ - **`Query.count()` fixes** — no longer capped by `BUNSANE_DEFAULT_QUERY_LIMIT`;
15
+ missing builder reset fixed.
16
+ - **`populate()` warms the component cache** (≤1000 components per query).
17
+ - **O(1) MemoryCache LRU** eviction.
18
+ - **Batched write-through** 2 cache round-trips per `entity.save()` regardless of
19
+ component count.
20
+ - **Framework `PreparedStatementCache` removed from the query hot path** Bun SQL
21
+ auto-prepares. `Query.noCache()` with no arguments is now a no-op; use
22
+ `noCache({ component: true })` to bypass the component cache.
23
+ - **Default pool size 10 → 20** (`POSTGRES_MAX_CONNECTIONS`).
24
+ - **New `'fulltext'` index type** for `@IndexedField` (tsvector GIN).
25
+
26
+ ### Internal refactors
27
+
28
+ - `core/Entity.ts` split into `core/entity/` submodules (pendingOps,
29
+ componentAccess, saveEntity, finders). Public API and import paths unchanged.
30
+ - Package now publishes with a `files` whitelist — tests, internal docs, and tooling
31
+ configs no longer ship to npm; `studio/dist` is now included so `enableStudio()`
32
+ works from the published package (run `bun run build` before `npm publish`).
33
+
34
+ ### BREAKING v0.4.0
35
+
36
+ - **`entity_components` table is no longer written or created by the framework.**
37
+ `components` (via `UNIQUE(entity_id, type_id)`) is now the single source of
38
+ entity↔component membership. The `entity_components` table receives no further
39
+ INSERTs, UPDATEs, or DELETEs on any save, delete, or soft-delete path.
40
+
41
+ **Impact on consumers:**
42
+ - Any application querying `entity_components` directly (e.g. raw `db.unsafe`
43
+ calls, external analytics, custom reports) must migrate those queries to
44
+ `components` see the inventory in `docs/ENTITY_COMPONENTS_REMOVAL_PLAN.md`.
45
+ - Orphaned `entity_components` tables in upgraded databases can be dropped
46
+ manually after verifying the upgrade succeeded:
47
+ ```sql
48
+ DROP TABLE entity_components;
49
+ ```
50
+ The framework emits a one-time info log at startup when the orphaned table is
51
+ detected, directing the operator to drop it.
52
+ - Databases with pre-dual-write history (written before Phase 1 of this plan)
53
+ or with external writers to `entity_components` may have membership records
54
+ that differ from `components`. Reconcile those differences before upgrading
55
+ by running a diff query (`SELECT entity_id, type_id FROM entity_components
56
+ EXCEPT SELECT entity_id, type_id FROM components`).
57
+
58
+ **Emergency rollback:** `BUNSANE_MEMBERSHIP_SOURCE=legacy` re-routes all
59
+ membership reads to `entity_components`. However this only works if the table
60
+ is populated. After Phase 3, that requires a manual backfill:
61
+ 1. `CreateEntityComponentTable()` recreates the DDL.
62
+ 2. `PopulateComponentIds()` backfills rows from `components`.
63
+
64
+ Both functions are exported from `database/DatabaseHelper.ts`.
65
+
66
+ ### Fixed
67
+
68
+ - **`UploadManager` no longer registers default providers asynchronously
69
+ (BUNSANE-007).** The constructor previously called an `async`
70
+ `initializeDefaultProviders()` that suspended on
71
+ `await localProvider.initialize()`, so the default `"local"` provider
72
+ was registered in a *later* microtask after any consumer's
73
+ synchronous `registerStorageProvider("local", custom)` override and
74
+ silently clobbered it. Result: uploads via `"local"` always wrote to
75
+ the default `./public` regardless of a custom `basePath`/`UPLOAD_ROOT`
76
+ provider. Default registration is now fully synchronous, and
77
+ `LocalStorageProvider` creates its base directory in its constructor
78
+ (`initialize()` retained as an idempotent no-op for the
79
+ `StorageProvider` contract and S3 parity). A custom `"local"` provider
80
+ registered immediately after `getInstance()` now survives.
81
+
82
+ ### Added (v0.3.2 AbortSignal propagation + DB observability)
83
+
84
+ - **AbortSignal threading into `Query.exec` + DataLoaders.** Resolvers
85
+ invoked from a GraphQL request now receive the request's `AbortSignal`
86
+ via the request-context plugin. When the framework's 30s wall-clock
87
+ fires (`core/app/requestRouter.ts`), in-flight `db.unsafe()` queries
88
+ are cancelled through Bun's `SQL.Query.cancel()`. Without this an
89
+ aborted request leaked its backend connection into
90
+ `idle in transaction` under pgbouncer transaction-mode pooling,
91
+ cascading into pool starvation under sustained timeout pressure.
92
+ Public surface: `Query.exec({ signal })`, `Query.count({ signal })`,
93
+ `Query.estimatedCount(component, { signal })`,
94
+ `Query.findOneById(id, { signal })`,
95
+ `Query.explainAnalyze(buffers, { signal })`,
96
+ `createRequestLoaders(db, cache?, signal?, perRequest?)`.
97
+ Reuses helper `runWithSignal` extracted to `database/cancellable.ts`
98
+ and shared with the existing `Entity.doSave` / `Entity.doDelete`
99
+ abort paths.
100
+
101
+ - **DB roundtrip observability (`database/instrumentedDb.ts`).** Every
102
+ `db.unsafe()` callsite in `Query.ts`, `RequestLoaders.ts` and the
103
+ shared `PreparedStatementCache.execute` now routes through
104
+ `timedUnsafe`. Tracks `totalCount`, `totalMs`, `maxMs`, `avgMs`,
105
+ `slowCount`, `abortedCount`, `inFlightMax`, plus per-DataLoader-kind
106
+ counters. Exposed at `/metrics` under the new `db` key. Calls over
107
+ `BUNSANE_DB_SLOW_MS` (default 500ms, set 0 to disable warn) log a
108
+ structured `Slow DB call` warning with a SQL snippet.
109
+
110
+ - **Per-request stats on access + timeout logs.** GraphQL request
111
+ context now captures `operationName`, `dataLoaderCalls`
112
+ (entity / component / relation), and `dbQueryCount`. These attach to
113
+ the underlying `Request` via `__bunsaneStats` so the HTTP router's
114
+ catch block and `AccessLog` middleware can include them in every
115
+ log line. The previous `Request failed after 30004ms: POST /graphql`
116
+ log now carries enough fields to identify the offending operation
117
+ without re-running production with a debug build. Timeout warn log
118
+ also includes operation name when reachable.
119
+
120
+ ### Env vars added
121
+
122
+ - `BUNSANE_DB_SLOW_MS` (default `500`) per-call DB threshold for
123
+ slow log + `slowCount` metric. Set `0` to suppress the warn (stats
124
+ still accumulate).
125
+
126
+ ### Backward compatibility
127
+
128
+ All additions are opt-in. Existing apps see no behavior change:
129
+ `Query.exec()`, `Query.count()`, `createRequestLoaders(db, cache)`,
130
+ and `preparedStatementCache.execute(s, p, db)` retain their pre-0.3.2
131
+ signatures. `/metrics` gains a `db` key (pure addition). Log lines
132
+ gain fields but preserve existing ones.
133
+
134
+ ### Added (HR-Screening ticket batch BUNSANE-002..006)
135
+
136
+ - **`@ScheduledTask` allows entity-less time-based tasks.** Previously
137
+ `SchedulerManager.registerTask` rejected tasks without `query` or
138
+ `componentTarget`, contradicting documented "runs every hour" examples.
139
+ Time-based tasks now register successfully and invoke the handler with
140
+ no entity argument on each tick. Existing entity-targeted tasks
141
+ unchanged. Ticket BUNSANE-002.
142
+
143
+ - **`Entity.requireComponents(ctors)` hydrator.** Batched-load helper
144
+ that ensures the given component constructors are present on the
145
+ in-memory `componentList`. Required before `set` / `save` flows that
146
+ may trigger `@ComponentTargetHook` — hook matching reads
147
+ `componentList()` (in-memory only), so tag components must be loaded
148
+ first for the hook to fire. Ticket BUNSANE-003.
149
+
150
+ - **`ServiceRegistry` class named-exported.** `service/ServiceRegistry.ts`
151
+ now exports the class as named alongside the existing default-instance
152
+ export. Available via `service/index.ts` as `ServiceRegistryClass` for
153
+ type/subclass use; existing `ServiceRegistry` import remains the
154
+ singleton instance for backward compatibility. Ticket BUNSANE-004.
155
+
156
+ - **`CacheManager.invalidateEntities(ids: string[])`.** Batched helper
157
+ that invalidates both the entity-existence cache and all component
158
+ caches for a list of IDs. Call after a raw-SQL write (`db.unsafe`)
159
+ that bypasses `Entity.set` / `Entity.save`. Ticket BUNSANE-005.
160
+
161
+ - **`Entity.reload(opts?)` refresher.** Discards in-memory component
162
+ state and re-hydrates from the `components` table. Preserves entity
163
+ identity callers holding a reference see fresh data on the same
164
+ instance. Use after raw-SQL writes or when a sibling `Entity`
165
+ instance with the same id mutated persisted data. Ticket BUNSANE-006.
166
+
167
+ - **Empty-string filter values supported.** `Query.filter(field, op, '')`
168
+ and the downstream SQL emit path (`ComponentInclusionNode`,
169
+ `PreparedStatementCache.execute`, `Query.doExec` / `doCount` /
170
+ `doAggregate` param validators) previously rejected empty /
171
+ whitespace-only values with "would cause PostgreSQL UUID parsing errors".
172
+ JSONB text extraction (`c.data->>'field'`) returns text, so `= ''` /
173
+ `!= ''` / `LIKE ''` are legitimate for text fields. The UUID-cast path
174
+ is gated by a value-side regex that an empty string cannot match, so
175
+ unsafe casts never fire. `findById('')` still throws — entity IDs
176
+ remain UUID-typed.
177
+
178
+ - **`Entity.drainPendingSideEffects(timeoutMs)`.** Drainable tracking
179
+ for post-commit work scheduled via `queueMicrotask` from `save()`
180
+ (cache invalidation + lifecycle hooks). Wired into `App.shutdown`
181
+ after `drainPendingCacheOps`. Tests under PGlite can call this in
182
+ `beforeAll` to settle prior-file background work before asserting.
183
+ Partial mitigation for BUNSANE-001 (Bun SQL / PGlite visibility race
184
+ see `CLAUDE.md` PGlite section for full context).
185
+
186
+ ### Fixed (PR E outbox, cache, query hardening)
187
+
188
+ - **OutboxWorker publishes to Redis concurrently and marks rows in bulk.**
189
+ Previously `processBatch` awaited each `publisher.xadd` serially inside
190
+ the PG transaction, holding `FOR UPDATE` row locks for up to N ×
191
+ `commandTimeout` when Redis was slow. Now uses `Promise.allSettled` to
192
+ publish the whole batch in parallel worst-case lock hold drops to a
193
+ single xadd timeout. Followed by a single bulk `UPDATE … WHERE id IN
194
+ …` instead of N serial updates. Tickets H-DB-1 (partial full fix
195
+ needs claim-via-column redesign so Redis latency is outside the PG
196
+ transaction entirely) and H-DB-3.
197
+
198
+ - **`Entity.save` pre-flights `ComponentRegistry.getReadyPromise` outside
199
+ the transaction.** Previously `doSave` awaited registry readiness from
200
+ inside `executeSave`, so a slow DDL (partition creation) would keep a PG
201
+ transaction idle. Pre-flight loop in `save()` awaits readiness before
202
+ opening the transaction; `doSave` now only asserts readiness and throws
203
+ if a caller bypassed `save()`. Ticket H-DB-4.
204
+
205
+ - **Entity.set / Entity.remove fire-and-forget cache ops now drainable on
206
+ shutdown.** Previously `setImmediate(async () => { })` was untracked,
207
+ so SIGTERM could abandon in-flight cache writes. `Entity.pendingCacheOps`
208
+ is a drainable `Set<Promise<void>>`, and `Entity.drainPendingCacheOps`
209
+ is awaited by `App.shutdown` between HTTP drain and cache disconnect.
210
+ Ticket H-CACHE-1.
211
+
212
+ - **`CacheManager.shutdownProvider` descends into `MultiLevelCache` layers.**
213
+ Previously only checked the top-level provider for `disconnect` /
214
+ `stopCleanup` methods, so a MultiLevelCache deployment left its inner
215
+ MemoryCache cleanup timer and Redis connection alive forever. Now
216
+ dispatches to `getL1Cache()` and `getL2Cache()` when available. Ticket
217
+ H-CACHE-2.
218
+
219
+ - **`setComponentWriteThrough` preserves `createdAt` across updates.**
220
+ Previously every write-through stamped `createdAt: new Date()`,
221
+ corrupting the timeline across consecutive updates. Now peeks the
222
+ existing cache entry and preserves its `createdAt` when present; only
223
+ `updatedAt` is stamped fresh. Full fix (BaseComponent tracking
224
+ timestamps natively) deferred. Ticket H-CACHE-3.
225
+
226
+ - **Default query limit applied when `.take()` is omitted.** `Query.exec()`
227
+ now applies a framework-level default LIMIT
228
+ (env `BUNSANE_DEFAULT_QUERY_LIMIT`, default 10000, 0 to disable) and
229
+ emits a warning so runaway queries are visible. Ticket H-QUERY-1.
230
+
231
+ - **OrNode debug `console.log` traces removed from the production path.**
232
+ Ticket H-QUERY-2.
233
+
234
+ - **`unregisterDecoratedHooks` now actually unregisters.** Previously a
235
+ no-op stub that warned to stderr. Hook IDs returned from each
236
+ registration are stored in a `WeakMap<instance, string[]>` and passed
237
+ to `EntityHookManager.removeHook` on tear-down. Enables per-instance
238
+ cleanup in tests and service destruction. Ticket H-HOOK-3.
239
+
240
+ ### Fixed (PR D — scheduler + hook concurrency hardening)
241
+
242
+ - **Entity.add / Entity.set / Entity.remove hook calls no longer leak
243
+ unhandled rejections.** `EntityHookManager.executeHooks` is async, but
244
+ the three mutating methods previously invoked it without `await` and the
245
+ surrounding `try/catch` captured only synchronous throws. A hook
246
+ declared `async` that rejected escaped as an unhandled rejection. `set`
247
+ now `await`s consistently; `add` and `remove` remain synchronous (to
248
+ preserve their fluent-chain / boolean signatures) and attach a
249
+ `.catch` to the returned promise so rejections are logged rather than
250
+ escaping. Ticket H-HOOK-1.
251
+
252
+ - **Hook timeout timers no longer leak and late rejections no longer
253
+ escape.** All four timeout race sites in `EntityHookManager` (sync path,
254
+ async-parallel path, sync-batch path, async-batch path) now capture the
255
+ `setTimeout` handle and `clearTimeout` on normal completion, and
256
+ attach a detached `.catch` to the hook callback promise so a rejection
257
+ that arrives after the race has been decided is logged rather than
258
+ emitted as an unhandled rejection. Tickets H-HOOK-2 / H-MEM-2.
259
+
260
+ - **SchedulerManager task interval no longer burns lock attempts for a
261
+ still-running task.** `doExecuteTask` now skips early if
262
+ `taskInfo.isRunning` is true, avoiding a wasted PG advisory-lock
263
+ round-trip every tick when execution outlasts the interval. Increments
264
+ `skippedExecutions`. Ticket H-SCHED-1.
265
+
266
+ - **Scheduled-task retry timer is now tracked and cleared on stop.**
267
+ `handleTaskFailure` previously scheduled retries with a bare
268
+ `setTimeout` whose handle was never stored, so `stop()` could not
269
+ clear it and the retry fired post-shutdown against a closed DB pool.
270
+ The retry handle is now registered in `intervals` under
271
+ `<taskId>:retry:<n>` and self-deletes once fired. The retry callback
272
+ also checks `isRunning` before executing. Tickets H-SCHED-2 /
273
+ H-SCHED-3.
274
+
275
+ - **DistributedLock re-entry now reports overlap instead of success.**
276
+ `tryAcquire` previously returned `acquired: true` when the instance
277
+ already held the lock for `taskId`, which meant retry + interval could
278
+ both enter `executeTask` concurrently. Now returns
279
+ `acquired: false` so the second caller skips defense-in-depth on
280
+ top of the caller-side `isRunning` guard. Ticket H-SCHED-4.
281
+
282
+ - **`executeWithTimeout` no longer leaks late rejections.** A scheduled
283
+ task that rejects after its wrapper timed out previously produced an
284
+ unhandled rejection (the wrapper was already settled). The wrapper now
285
+ uses a `settled` flag and logs late rejections instead of propagating.
286
+ Ticket H-SCHED-5.
287
+
288
+ - **DistributedLock `reservePromise` nulls on reject.** Previously, if
289
+ `db.reserve()` rejected (pool exhausted, shutdown mid-call), the
290
+ rejected promise was cached in `reservePromise` forever and every
291
+ subsequent `ensureReserved` received the same rejection. Now nulls the
292
+ promise in the reject handler so future callers retry a fresh reserve.
293
+ Ticket H-DB-2.
294
+
295
+ - **`App.waitForAppReady` no longer polls indefinitely.** Replaced the
296
+ 100ms `setInterval` with a one-shot phase listener and default 60s
297
+ timeout. A boot failure that never reaches `APPLICATION_READY` now
298
+ surfaces as a rejection instead of leaking a timer for process
299
+ lifetime. Ticket H-MEM-1.
300
+
301
+ ### Security
302
+
303
+ - **SQL injection hardening across Query layer.** Identifiers (component
304
+ table names, JSON field paths, ORDER BY properties, text-search language)
305
+ interpolated into SQL via `db.unsafe(...)` or template literals are now
306
+ validated against strict allow-lists before use. Added `query/SqlIdentifier.ts`
307
+ with `assertIdentifier`, `assertComponentTableName`, `assertFieldPath`,
308
+ `assertTsLanguage`. Applied at `Query.estimatedCount`, `Query.doAggregate`,
309
+ `ComponentInclusionNode` sort expressions (3 sites), and
310
+ `FullTextSearchBuilder` (3 sites + factory). Throws `InvalidIdentifierError`
311
+ on unsafe input. Ticket C08.
312
+
313
+ - **GraphQL depth limit hard minimum enforced.** Previously `maxDepth: 0`
314
+ or `undefined` silently disabled the depth-limit guard, allowing CPU/memory
315
+ DoS via deeply nested queries. Now `createYogaInstance` enforces a hard
316
+ floor of 15 regardless of input; callers can raise but cannot disable.
317
+ Ticket C06.
318
+
319
+ - **Request AbortSignal now propagates into Yoga and REST handlers.** The
320
+ 30s wall-clock timer previously only logged a warning; the signal was
321
+ never forwarded downstream. Request timeouts (and client disconnects) now
322
+ cancel in-flight resolvers, DB queries, and external calls. Uses
323
+ `AbortSignal.any` (Bun/Node 20+) with a manual combiner fallback.
324
+ Ticket C05.
325
+
326
+ ### Fixed
327
+
328
+ - **Sync lifecycle hooks now awaited, preventing unhandled rejections.**
329
+ `EntityHookManager.executeHooks` previously discarded the return value of
330
+ `hook.callback(event)` on the sync path when no timeout was configured.
331
+ A hook mistakenly declared `async: false` but implemented as an
332
+ `async function` would silently throw unhandled rejections, crashing the
333
+ process under strict mode. Sync path now awaits consistently. Ticket C13.
334
+
335
+ - **`createRequestContextPlugin` auto-applied by default.** Previously
336
+ opt-in (and the export was commented out of the root barrel), so any app
337
+ using `@BelongsTo` / `@HasMany` relations silently fell into N+1 query
338
+ patterns. `App` now prepends the plugin to Yoga plugins by default. Opt
339
+ out via `App.disableRequestContextPlugin()` if supplying your own
340
+ DataLoader layer. Ticket C07.
341
+
342
+ - **Redis cache no longer causes unbounded heap growth when Redis is
343
+ unreachable.** `enableOfflineQueue` now defaults to `false` so commands
344
+ fail fast and the caller's `try/catch` treats it as a cache miss instead
345
+ of queuing indefinitely. Can be overridden per-deployment via
346
+ `REDIS_ENABLE_OFFLINE_QUEUE=true` when you accept the memory risk.
347
+ Ticket C02.
348
+
349
+ - **Redis reconnect storm capped.** `retryStrategy` now returns `null`
350
+ after `maxReconnectAttempts` (default 20) so a permanently unreachable
351
+ Redis cannot spin forever, saturating logs and keeping the ioredis
352
+ state machine busy. Configurable via `REDIS_MAX_RECONNECT_ATTEMPTS`.
353
+ Default inter-attempt delay also raised from `times * 50` to
354
+ `times * 200` (capped at 2s) for a gentler back-off. Ticket C03.
355
+
356
+ - **`App.init()` now awaits `CacheManager.initialize()`.** Previously only
357
+ `getInstance()` was called so pub/sub cross-instance invalidation was
358
+ never set up and any app-supplied cache config was silently ignored.
359
+ Added `App.setCacheConfig(config)` so callers can supply a partial
360
+ config that is merged with `defaultCacheConfig` and passed to
361
+ `initialize()`. Ticket C04.
362
+
363
+ - **`Entity.doDelete` no longer leaks `idle in transaction` backends on timeout.**
364
+ Same AbortController + in-flight query cancellation pattern as `Entity.save`.
365
+ Post-commit cache invalidation and lifecycle hooks moved out of the save
366
+ budget via `queueMicrotask`. Ticket C01.
367
+
368
+ - **`SYSTEM_READY` phase errors are no longer swallowed silently.**
369
+ Previously a schema-build, REST-registration, or scheduler-init failure was
370
+ caught and only logged, leaving the app stuck at `isReady=false` with
371
+ `/health/ready` returning 503 forever and k8s rollouts blocked indefinitely.
372
+ Now marks the app unready, logs at fatal level, and exits so the orchestrator
373
+ can restart. In tests, rethrows instead of exiting. Ticket C09.
374
+
375
+ - **HTTP server drain is now awaited before tearing down dependencies.**
376
+ `server.stop(false)` previously initiated drain but was not awaited, so the
377
+ scheduler / cache / DB pool closed while requests were still executing,
378
+ causing cascade failures in the final seconds of shutdown. Shutdown now
379
+ polls pending requests (bounded by `shutdownGracePeriod`) before force-close,
380
+ then stops each subsystem in order. Ticket C10.
381
+
382
+ - **ApplicationLifecycle phase listeners are now captured and removed on
383
+ shutdown.** Five singletons (`App`, `EntityManager`, `EntityHookManager`,
384
+ `SchedulerManager`, `ServiceRegistry`) previously registered listeners
385
+ without storing refs, so each `init()` call (common in tests) stacked
386
+ listeners on the singleton `EventTarget`, permanently leaking memory and
387
+ firing duplicate phase handlers. Each now captures the listener reference
388
+ and exposes a `dispose()` / `disposeLifecycleIntegration()` method called
389
+ from `App.shutdown()`. `init()` paths are also idempotent. Ticket C11.
390
+
391
+ - **`ApplicationLifecycle.waitForPhase` replaced 100ms busy-loop with a
392
+ listener-based Promise.** Previously a `while (currentPhase !== phase)`
393
+ loop polling every 100ms; if the target phase was never reached (see
394
+ SYSTEM_READY fix above) every caller hung forever. Now attaches a one-shot
395
+ phase listener + `timeoutMs` (default 30s). Rejects with a descriptive
396
+ error on timeout. Ticket C12.
397
+
398
+ - **`SchedulerManager.stop()` now awaits in-flight tasks before returning.**
399
+ Previously cleared timers and returned immediately; any task mid-execution
400
+ continued running against a DB pool that was about to close in
401
+ `App.shutdown()`. Now tracks each `executeTask` promise in a Set, and
402
+ `stop(drainTimeoutMs = 15_000)` awaits `Promise.allSettled` bounded by the
403
+ timeout. Scheduler listener also disposed. Ticket C14.
404
+
405
+ - **Process-level error handlers (`unhandledRejection`, `uncaughtException`)
406
+ and signal handlers (`SIGTERM`, `SIGINT`) now registered at the top of
407
+ `App.init()` instead of only in `start()`.** Previously any rejection
408
+ during boot (DB prep, component registration, cache init) was silently
409
+ discarded by the runtime. Signal handlers now use `process.once` so a
410
+ double SIGTERM cannot fire two concurrent shutdown paths. Ticket C15.
411
+
412
+ - **`Entity.save` no longer leaks `idle in transaction` backends on timeout.**
413
+ The previous implementation wrapped `db.transaction(...)` in a JS `setTimeout`
414
+ and rejected the outer promise when the timer fired, but the underlying Bun
415
+ SQL transaction continued on the server with no `COMMIT`/`ROLLBACK` ever
416
+ sent. Under pgbouncer `transaction` pool mode this pinned backend sessions
417
+ permanently, exhausting the pool and cascading into further save timeouts.
418
+
419
+ `Entity.save` now threads an `AbortSignal` into `doSave`. When the wall-clock
420
+ timer fires the signal is aborted, the in-flight `SQL.Query` is cancelled
421
+ via `.cancel()`, and the cancellation propagates out of the transaction
422
+ callback, triggering Bun SQL's automatic `ROLLBACK` and releasing the
423
+ pooled connection. The `DB_STATEMENT_TIMEOUT` env var (already supported
424
+ in `database/index.ts`) acts as a PostgreSQL-side backstop.
425
+
426
+ See `docs` / handoff dated 2026-04-18 for incident details.
427
+
428
+ ### Changed
429
+
430
+ - **Post-commit side effects (cache invalidation, lifecycle hooks) no longer
431
+ block `Entity.save`.** `handleCacheAfterSave` and `EntityHookManager.executeHooks`
432
+ are now queued via `queueMicrotask` after the transaction commits. Save
433
+ resolves as soon as the DB write is durable; cache or hook latency cannot
434
+ consume the save budget or surface as save failures. Errors are logged
435
+ and swallowed (matching prior error-handling behavior).
436
+
437
+ ### Added
438
+
439
+ - **`DB_SAVE_PROFILE=true` env var** — when set, `Entity.save` logs per-phase
440
+ timings (`db`, `cache`, `hooks`, `total`) at info level. Off by default.
441
+
442
+ - **Integration tests** in `tests/integration/entity/Entity.saveTimeout.test.ts`
443
+ covering: aborted save leaves no partial rows, pool stays healthy after
444
+ repeated aborts, backwards-compatible signal-less `doSave`, non-blocking
445
+ post-commit work.