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.
- package/CHANGELOG.md +445 -318
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1043
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +64 -6
- package/core/RequestLoaders.ts +187 -36
- package/core/SchedulerManager.ts +28 -600
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +85 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +15 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +310 -0
- package/core/app/restRegistry.ts +80 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +666 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +161 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +173 -267
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/AccessLog.ts +8 -1
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +20 -5
- package/database/cancellable.ts +35 -0
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -0
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +203 -59
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -367
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- 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
|
-
##
|
|
6
|
-
|
|
7
|
-
###
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
`
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- **`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
`
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
`
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
`
|
|
138
|
-
|
|
139
|
-
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
Ticket
|
|
160
|
-
|
|
161
|
-
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
`
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
`
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
`
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
###
|
|
302
|
-
|
|
303
|
-
- **
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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.
|