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