bunsane 0.3.1 → 0.3.2
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/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +52 -0
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/Entity.ts +10 -33
- package/core/RequestContext.ts +85 -36
- package/core/RequestLoaders.ts +89 -31
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +94 -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 +55 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +309 -0
- package/core/app/restRegistry.ts +72 -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 +621 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +118 -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 +126 -9
- package/core/middleware/AccessLog.ts +8 -1
- package/database/PreparedStatementCache.ts +12 -3
- package/database/cancellable.ts +22 -0
- package/database/instrumentedDb.ts +141 -0
- package/docs/RFC_APP_REFACTOR.md +248 -0
- package/docs/RFC_REFACTOR_TARGETS.md +251 -0
- package/package.json +1 -1
- package/query/Query.ts +53 -20
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
- package/tests/integration/query/Query.abort.test.ts +66 -0
- package/tests/unit/cache/CacheManager.test.ts +132 -1
- package/tests/unit/database/cancellable.test.ts +81 -0
- package/tests/unit/database/instrumentedDb.test.ts +160 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# RFC: Remaining Refactor Targets
|
|
2
|
+
|
|
3
|
+
**Status:** Backlog / planning
|
|
4
|
+
**Author:** uray@qyubit.io (drafted with Claude)
|
|
5
|
+
**Date:** 2026-05-09
|
|
6
|
+
**Prior work:** `core/ArcheType.ts` split — commit `a886b45`, merged to `staging` (`fd75f21`).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Purpose
|
|
11
|
+
|
|
12
|
+
After `ArcheType.ts` was split (3064 → 1032 LOC across 8 modules under `core/archetype/`), several other files in `core/` remain large and concern-dense. This RFC enumerates them in priority order so future refactor work has a reference.
|
|
13
|
+
|
|
14
|
+
This is a **planning document**, not an approval-bound RFC. Each target listed here would get its own scoped RFC (like `RFC_APP_REFACTOR.md`) before work starts.
|
|
15
|
+
|
|
16
|
+
## 2. Selection Criteria
|
|
17
|
+
|
|
18
|
+
Files are ordered by combined score of:
|
|
19
|
+
|
|
20
|
+
1. **Size** (LOC) — bigger = harder to read, harder to test.
|
|
21
|
+
2. **Concern density** — number of distinct responsibilities mixed in one class/module.
|
|
22
|
+
3. **Blast radius** — how many tests/consumers depend on the file booting cleanly.
|
|
23
|
+
4. **Refactor ROI** — likelihood that splitting yields independently testable modules without behavior change.
|
|
24
|
+
|
|
25
|
+
Pure "library leaf" files (formatter helpers, fixed schemas) are excluded even when large, because splitting them wouldn't reveal new structure.
|
|
26
|
+
|
|
27
|
+
## 3. Targets
|
|
28
|
+
|
|
29
|
+
### 3.1 `core/App.ts` — 1477 LOC — **highest priority**
|
|
30
|
+
|
|
31
|
+
**Why next:** Most God-class. Mixes:
|
|
32
|
+
|
|
33
|
+
- Application lifecycle (phase orchestration, DB prep, component registration).
|
|
34
|
+
- HTTP server (Bun.serve setup, request routing, signal/disconnect plumbing).
|
|
35
|
+
- CORS (origin validation, header injection, preflight handling).
|
|
36
|
+
- OpenAPI spec generation (per-endpoint registration, Swagger UI HTML).
|
|
37
|
+
- GraphQL setup (Yoga instance, depth/complexity limits, plugin pipe, context factory wrap).
|
|
38
|
+
- REST routing (endpoint collection, dispatch).
|
|
39
|
+
- Plugin pipeline (`addPlugin`, `addYogaPlugin`).
|
|
40
|
+
- Scheduler bootstrap (`SchedulerManager` init, scheduled task registration per service).
|
|
41
|
+
- Health endpoints (`/health`, `/health/ready`, `/health/remote`).
|
|
42
|
+
- Metrics endpoint (`/metrics`).
|
|
43
|
+
- Studio routing (`/studio/api/*` — 107 LOC inline).
|
|
44
|
+
- Remote subsystem bootstrap (RemoteManager init, handler registration).
|
|
45
|
+
- Process signal & error handlers (SIGTERM, SIGINT, unhandledRejection, uncaughtException).
|
|
46
|
+
- Graceful shutdown ordering (HTTP → scheduler → remote → cache → DB).
|
|
47
|
+
- Prepared-statement cache warm-up.
|
|
48
|
+
|
|
49
|
+
`init()` is a giant `switch (phase)` (lines 198–476) where each case runs 30–80 LOC of business logic. `handleRequest()` is ~430 LOC across 8+ branches.
|
|
50
|
+
|
|
51
|
+
**Detailed plan:** see `RFC_APP_REFACTOR.md` (drafted, awaiting approval).
|
|
52
|
+
|
|
53
|
+
**Status:** RFC drafted, not started.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
### 3.2 `core/Entity.ts` — 1212 LOC
|
|
58
|
+
|
|
59
|
+
**Why next:** `save()` alone likely 300+ LOC. Cache ops inline. Mixes:
|
|
60
|
+
|
|
61
|
+
- Component add/get/remove (in-memory + persisted).
|
|
62
|
+
- DB persistence (insert/update/delete with abort signal + per-component partitioned writes).
|
|
63
|
+
- Cache write-through / write-invalidate strategies (L1 + L2 + pubsub).
|
|
64
|
+
- Hook dispatch (pre-save, post-save, post-delete) via `EntityHookManager`.
|
|
65
|
+
- Pending side-effects queue (`Entity.pendingCacheOps`, `Entity.pendingSideEffects` static drain methods for shutdown).
|
|
66
|
+
- Profile timing (`DB_SAVE_PROFILE`).
|
|
67
|
+
- Abort signal handling (timeout + client disconnect cancellation).
|
|
68
|
+
- Component-ready preflight (`ComponentRegistry.getReadyPromise`).
|
|
69
|
+
- Static finders (`FindById`, etc.).
|
|
70
|
+
|
|
71
|
+
**Proposed split direction:**
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
core/entity/
|
|
75
|
+
saveEntity.ts # save() body — DB writes, abort, profile
|
|
76
|
+
cacheStrategies.ts # write-through, write-invalidate per component
|
|
77
|
+
pendingOps.ts # pendingCacheOps + pendingSideEffects + drain methods
|
|
78
|
+
componentAccess.ts # add/get/remove + in-memory cache
|
|
79
|
+
finders.ts # static FindById, etc.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Class skeleton + public API stays in `Entity.ts`.
|
|
83
|
+
|
|
84
|
+
**Risks:**
|
|
85
|
+
|
|
86
|
+
- `Entity.save()` is hot-path. Per-step micro-benchmark (save 1000 entities) before/after each extraction.
|
|
87
|
+
- Hook ordering is load-bearing (per `MEMORY.md` H-HOOK-1..3, C13). Don't reorder pre/post-commit phases.
|
|
88
|
+
- The PGlite Bun-SQL ACK race (documented in `CLAUDE.md`) is in this file's blast radius — keep `await entity.save()` semantics byte-identical.
|
|
89
|
+
|
|
90
|
+
**Estimated effort:** larger than App.ts because of perf sensitivity. ~6–8 hours plus benchmark validation.
|
|
91
|
+
|
|
92
|
+
**Status:** Not started.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### 3.3 `core/SchedulerManager.ts` — 932 LOC
|
|
97
|
+
|
|
98
|
+
**Why next:** Scheduling logic + distributed lock + hook orchestration in one class.
|
|
99
|
+
|
|
100
|
+
Concerns to disentangle:
|
|
101
|
+
|
|
102
|
+
- Cron expression parsing + schedule evaluation.
|
|
103
|
+
- Task registration & lookup (`registerScheduledTasks`).
|
|
104
|
+
- Per-task execution loop with skip-on-running guard (H-SCHED-1..5 in memory).
|
|
105
|
+
- Distributed lock (`DistributedLock`) acquisition + release semantics.
|
|
106
|
+
- Lifecycle integration (`disposeLifecycleIntegration`, awaiting in-flight tasks on `stop()` per C14).
|
|
107
|
+
- Metrics (`getMetrics`).
|
|
108
|
+
- Error handling per task.
|
|
109
|
+
|
|
110
|
+
**Proposed split direction:**
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
core/scheduler/
|
|
114
|
+
cronEvaluator.ts # cron expression -> next-fire-time
|
|
115
|
+
taskRunner.ts # per-task execute loop + skip-on-running
|
|
116
|
+
lockCoordinator.ts # DistributedLock wiring
|
|
117
|
+
lifecycleHooks.ts # phase-listener + dispose
|
|
118
|
+
metrics.ts # getMetrics
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`SchedulerManager` keeps singleton + public API.
|
|
122
|
+
|
|
123
|
+
**Risks:**
|
|
124
|
+
|
|
125
|
+
- Concurrency hardening already done in v0.3.0 (H-SCHED-1..5). Refactor must preserve every guard. Property-based tests on the runner would help.
|
|
126
|
+
- Re-entry semantics on `DistributedLock` (memory: `acquired:false` on overlap). Don't change.
|
|
127
|
+
|
|
128
|
+
**Status:** Not started.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### 3.4 `core/EntityHookManager.ts` — 921 LOC
|
|
133
|
+
|
|
134
|
+
**Why next:** Hook registry + dispatch + lifecycle in one place.
|
|
135
|
+
|
|
136
|
+
Concerns:
|
|
137
|
+
|
|
138
|
+
- Hook registration (per-component, per-event).
|
|
139
|
+
- Dispatch ordering (pre vs post, sync vs async).
|
|
140
|
+
- Hook chain with timer leak fixes (memory: H-HOOK-2, H-MEM-2).
|
|
141
|
+
- Re-entry / recursion guard.
|
|
142
|
+
- Integration with `Entity.save()` post-commit microtask scheduling.
|
|
143
|
+
|
|
144
|
+
**Proposed split direction:**
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
core/hooks/
|
|
148
|
+
registry.ts # register/lookup
|
|
149
|
+
dispatcher.ts # dispatch loop + ordering
|
|
150
|
+
guards.ts # re-entry guard, timer cleanup
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`EntityHookManager` keeps public API.
|
|
154
|
+
|
|
155
|
+
**Risks:**
|
|
156
|
+
|
|
157
|
+
- Hook timing fixes (C13, H-HOOK-1..3) are load-bearing. Tests assert specific orderings.
|
|
158
|
+
- Cross-file coupling with `Entity.ts` — coordinate with §3.2 if both run in flight.
|
|
159
|
+
|
|
160
|
+
**Status:** Not started.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
### 3.5 `core/cache/CacheManager.ts` — 574 LOC
|
|
165
|
+
|
|
166
|
+
**Why next:** L1 (memory) + L2 (Redis) + strategies + pub/sub all-in-one. Already smaller than peers, so lower priority.
|
|
167
|
+
|
|
168
|
+
Concerns:
|
|
169
|
+
|
|
170
|
+
- Provider initialization (memory + Redis).
|
|
171
|
+
- Strategy dispatch (write-through vs write-invalidate).
|
|
172
|
+
- Cross-instance invalidation via Redis pub/sub (`instanceId` loop prevention).
|
|
173
|
+
- Cache stats / health (`ping`, `getStats`).
|
|
174
|
+
- Singleton lifecycle (`initialize` async, `shutdown`).
|
|
175
|
+
|
|
176
|
+
**Proposed split direction:**
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
core/cache/
|
|
180
|
+
CacheManager.ts # singleton + public API (kept)
|
|
181
|
+
strategies/
|
|
182
|
+
writeThrough.ts
|
|
183
|
+
writeInvalidate.ts
|
|
184
|
+
invalidation.ts # pub/sub coordinator
|
|
185
|
+
health.ts # ping + stats
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Risks:**
|
|
189
|
+
|
|
190
|
+
- `CacheManager.initialize()` is now async (BREAKING CHANGE per memory, 2026-02-17). Don't regress.
|
|
191
|
+
- Cross-instance loop prevention (`instanceId`) is load-bearing. Test with two instances on same Redis.
|
|
192
|
+
|
|
193
|
+
**Status:** Not started. Lowest priority of the five — defer until at least one peer refactor lands.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 4. Cross-Cutting Themes
|
|
198
|
+
|
|
199
|
+
Several patterns recur and would benefit from being decided once before any of these refactors start:
|
|
200
|
+
|
|
201
|
+
### 4.1 Extraction pattern
|
|
202
|
+
|
|
203
|
+
`ArcheType.ts` split established the pattern:
|
|
204
|
+
|
|
205
|
+
- Pure functions in submodules accept the class instance as a parameter (`buildFieldResolvers(archetype)`).
|
|
206
|
+
- Class methods become 1-line delegates via lazy `require()` to break circular type deps.
|
|
207
|
+
- Maps/state stay in the submodule that owns them, exported as `const`.
|
|
208
|
+
- Public API preserved by re-export from the parent file.
|
|
209
|
+
|
|
210
|
+
This pattern works well for ECS-style classes where the class is mostly a data bag with methods. **Re-use it for App, Entity, SchedulerManager, EntityHookManager.** `CacheManager` may want a different shape (provider injection) given its strategy variants.
|
|
211
|
+
|
|
212
|
+
### 4.2 Test infrastructure assumed stable
|
|
213
|
+
|
|
214
|
+
All five targets are exercised by the current 770-test suite (under `bun run test:pglite`). No target requires new test scaffolding before extraction starts; existing tests are sufficient guardrails for behavior preservation.
|
|
215
|
+
|
|
216
|
+
### 4.3 No DI introduction
|
|
217
|
+
|
|
218
|
+
Project rule (per `CLAUDE.md` and `MEMORY.md`): singletons + global exports, no dependency injection container. Extracted modules must respect this — pass `app: App`, `entity: Entity`, etc., not an injection token.
|
|
219
|
+
|
|
220
|
+
### 4.4 No bundled bug fixes
|
|
221
|
+
|
|
222
|
+
If a refactor reveals a latent bug (wrong ordering, missing guard, stale comment claim), file it separately. Refactor PRs must show "no behavior change" by passing the existing test suite unchanged.
|
|
223
|
+
|
|
224
|
+
## 5. Recommended Order
|
|
225
|
+
|
|
226
|
+
1. **`App.ts`** — RFC drafted, ready to start. Highest payoff: every test boots through it.
|
|
227
|
+
2. **`Entity.ts`** — Highest perf sensitivity but biggest readability win. Allocate benchmark time.
|
|
228
|
+
3. **`SchedulerManager.ts`** *or* **`EntityHookManager.ts`** — Either next. They're partially coupled (hooks fire from scheduler-triggered work), so coordinate.
|
|
229
|
+
4. **`CacheManager.ts`** — Last. Smallest of the five, already structured around providers.
|
|
230
|
+
|
|
231
|
+
This ordering minimizes risk because the most heavily-tested file goes first (more guardrails) and the perf-sensitive file goes early-second when there's still energy for benchmarking.
|
|
232
|
+
|
|
233
|
+
## 6. Anti-Goals
|
|
234
|
+
|
|
235
|
+
These are not refactors and should not be bundled:
|
|
236
|
+
|
|
237
|
+
- **Adding new abstractions** (router DSL, plugin SPI v2, hook framework). Out of scope for any of these.
|
|
238
|
+
- **Performance "improvements"** that change semantics. If a refactor reveals an O(n²) loop, file it separately.
|
|
239
|
+
- **API renaming** for "consistency". Public symbols stay byte-identical.
|
|
240
|
+
- **Comment cleanup pass** as a side effect. Touch only comments that are actively wrong after a code move.
|
|
241
|
+
|
|
242
|
+
## 7. Decision
|
|
243
|
+
|
|
244
|
+
This RFC requires no decision. It exists so the next person picking up refactor work has:
|
|
245
|
+
|
|
246
|
+
- A prioritized list.
|
|
247
|
+
- Concern inventory per file.
|
|
248
|
+
- Pre-identified risks per file.
|
|
249
|
+
- Cross-cutting guardrails (extraction pattern, no-DI rule, no bundled fixes).
|
|
250
|
+
|
|
251
|
+
When work starts on any one target, that target gets its own RFC and own branch (per the `RFC_APP_REFACTOR.md` template).
|
package/package.json
CHANGED
package/query/Query.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { QueryContext, QueryDAG, SourceNode, ComponentInclusionNode } from "./in
|
|
|
8
8
|
import { OrQuery } from "./OrQuery";
|
|
9
9
|
import { OrNode } from "./OrNode";
|
|
10
10
|
import { preparedStatementCache } from "../database/PreparedStatementCache";
|
|
11
|
+
import { timedUnsafe, type PerRequestCounters } from "../database/instrumentedDb";
|
|
11
12
|
import { getMetadataStorage } from "../core/metadata";
|
|
12
13
|
import { shouldUseDirectPartition } from "../core/Config";
|
|
13
14
|
import type { SQL } from "bun";
|
|
@@ -62,6 +63,21 @@ export interface QueryCacheOptions {
|
|
|
62
63
|
component?: boolean;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Options accepted by Query terminal methods (`exec`, `count`, `sum`, etc.).
|
|
68
|
+
* - `signal` cancels in-flight DB queries via Bun's `Query.cancel()` when
|
|
69
|
+
* fired. The request-scoped signal from `req.signal` is automatically
|
|
70
|
+
* threaded into resolver-level Query instances by the framework's
|
|
71
|
+
* GraphQL request context plugin; manual callers pass it explicitly.
|
|
72
|
+
* - `perRequest` is an opaque counter object incremented by the
|
|
73
|
+
* instrumented DB layer so per-request stats (dbQueryCount,
|
|
74
|
+
* dataLoaderCalls) are reported on access/timeout logs.
|
|
75
|
+
*/
|
|
76
|
+
export interface QueryExecOptions {
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
perRequest?: PerRequestCounters;
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
/**
|
|
66
82
|
* New Query class that uses DAG internally for better modularity and extensibility.
|
|
67
83
|
*
|
|
@@ -85,6 +101,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
85
101
|
private trx: SQL | undefined;
|
|
86
102
|
private skipPreparedCache: boolean = false;
|
|
87
103
|
private skipComponentCache: boolean = false;
|
|
104
|
+
private execSignal?: AbortSignal;
|
|
105
|
+
private execPerRequest?: PerRequestCounters;
|
|
88
106
|
|
|
89
107
|
/** Component constructors added to this query for type-safe access */
|
|
90
108
|
private _componentCtors: ComponentConstructor[] = [];
|
|
@@ -110,12 +128,12 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
110
128
|
return this;
|
|
111
129
|
}
|
|
112
130
|
|
|
113
|
-
public async findOneById(id: string): Promise<TypedEntity<TComponents> | null> {
|
|
131
|
+
public async findOneById(id: string, opts?: QueryExecOptions): Promise<TypedEntity<TComponents> | null> {
|
|
114
132
|
// Validate ID to prevent PostgreSQL UUID parsing errors
|
|
115
133
|
if (!id || typeof id !== 'string' || id.trim() === '') {
|
|
116
134
|
return null;
|
|
117
135
|
}
|
|
118
|
-
const entities = await this.findById(id).exec();
|
|
136
|
+
const entities = await this.findById(id).exec(opts);
|
|
119
137
|
return entities.length > 0 ? entities[0]! : null;
|
|
120
138
|
}
|
|
121
139
|
|
|
@@ -300,7 +318,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
300
318
|
return this;
|
|
301
319
|
}
|
|
302
320
|
|
|
303
|
-
public count(): Promise<number> {
|
|
321
|
+
public count(opts?: QueryExecOptions): Promise<number> {
|
|
322
|
+
this.applyExecOptions(opts);
|
|
304
323
|
return new Promise<number>((resolve, reject) => {
|
|
305
324
|
const timeout = setTimeout(() => {
|
|
306
325
|
logger.error(`Query count execution timeout`);
|
|
@@ -318,6 +337,17 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
318
337
|
});
|
|
319
338
|
}
|
|
320
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Apply terminal-method options to instance fields so internal helpers
|
|
342
|
+
* (doCount, doExec, populateComponents, doAggregate, …) can read them
|
|
343
|
+
* without threading parameters through every private method.
|
|
344
|
+
*/
|
|
345
|
+
private applyExecOptions(opts?: QueryExecOptions): void {
|
|
346
|
+
if (!opts) return;
|
|
347
|
+
if (opts.signal !== undefined) this.execSignal = opts.signal;
|
|
348
|
+
if (opts.perRequest !== undefined) this.execPerRequest = opts.perRequest;
|
|
349
|
+
}
|
|
350
|
+
|
|
321
351
|
/**
|
|
322
352
|
* Get an estimated count using PostgreSQL statistics.
|
|
323
353
|
* Much faster than exact count() for large tables - O(1) instead of O(n).
|
|
@@ -333,7 +363,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
333
363
|
* const approxCount = await new Query().with(User).estimatedCount(User);
|
|
334
364
|
* console.log(`Approximately ${approxCount} users`);
|
|
335
365
|
*/
|
|
336
|
-
public async estimatedCount(component: new (...args: any[]) => BaseComponent): Promise<number> {
|
|
366
|
+
public async estimatedCount(component: new (...args: any[]) => BaseComponent, opts?: QueryExecOptions): Promise<number> {
|
|
367
|
+
this.applyExecOptions(opts);
|
|
337
368
|
const typeId = ComponentRegistry.getComponentId(component.name);
|
|
338
369
|
if (!typeId) {
|
|
339
370
|
throw new Error(`Component ${component.name} not registered`);
|
|
@@ -354,7 +385,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
354
385
|
? `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`
|
|
355
386
|
: `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'entity_components'`;
|
|
356
387
|
|
|
357
|
-
const result = await dbConn
|
|
388
|
+
const result = await timedUnsafe<any[]>(dbConn, sql, [tableName || 'entity_components'], this.execSignal, this.execPerRequest);
|
|
358
389
|
|
|
359
390
|
if (!result || result.length === 0 || result[0].estimate === null) {
|
|
360
391
|
// Fallback to exact count if statistics not available
|
|
@@ -410,13 +441,13 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
410
441
|
|
|
411
442
|
if (this.skipPreparedCache) {
|
|
412
443
|
// Bypass cache - execute directly
|
|
413
|
-
countResult = await dbConn
|
|
444
|
+
countResult = await timedUnsafe<any[]>(dbConn, countSql, result.params, this.execSignal, this.execPerRequest);
|
|
414
445
|
} else {
|
|
415
446
|
// Check prepared statement cache
|
|
416
447
|
// Add 'count:' prefix to differentiate count queries from exec queries
|
|
417
448
|
const cacheKey = 'count:' + this.context.generateCacheKey();
|
|
418
449
|
const { statement, isHit } = await preparedStatementCache.getOrCreate(countSql, cacheKey, dbConn);
|
|
419
|
-
countResult = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
450
|
+
countResult = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
|
|
420
451
|
}
|
|
421
452
|
|
|
422
453
|
// Debug logging
|
|
@@ -603,11 +634,11 @@ AND c.deleted_at IS NULL`;
|
|
|
603
634
|
let aggregateResult: any[];
|
|
604
635
|
|
|
605
636
|
if (this.skipPreparedCache) {
|
|
606
|
-
aggregateResult = await dbConn
|
|
637
|
+
aggregateResult = await timedUnsafe<any[]>(dbConn, aggregateSql, result.params, this.execSignal, this.execPerRequest);
|
|
607
638
|
} else {
|
|
608
639
|
const cacheKey = `${aggregateType.toLowerCase()}:${typeId}:${field}:` + this.context.generateCacheKey();
|
|
609
640
|
const { statement } = await preparedStatementCache.getOrCreate(aggregateSql, cacheKey, dbConn);
|
|
610
|
-
aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
641
|
+
aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
|
|
611
642
|
}
|
|
612
643
|
|
|
613
644
|
// Debug logging
|
|
@@ -645,7 +676,8 @@ AND c.deleted_at IS NULL`;
|
|
|
645
676
|
* @returns Promise resolving to array of TypedEntity with accumulated component types
|
|
646
677
|
*/
|
|
647
678
|
@timed("Query.exec")
|
|
648
|
-
public async exec(): Promise<TypedEntity<TComponents>[]> {
|
|
679
|
+
public async exec(opts?: QueryExecOptions): Promise<TypedEntity<TComponents>[]> {
|
|
680
|
+
this.applyExecOptions(opts);
|
|
649
681
|
// Apply default LIMIT so unbounded queries cannot load entire tables
|
|
650
682
|
// into memory. Configurable via BUNSANE_DEFAULT_QUERY_LIMIT, 0 to
|
|
651
683
|
// disable. When the default is applied without an explicit .take(),
|
|
@@ -806,12 +838,12 @@ AND c.deleted_at IS NULL`;
|
|
|
806
838
|
if (this.orQuery || this.skipPreparedCache) {
|
|
807
839
|
// For OR queries or explicit cache bypass, execute directly
|
|
808
840
|
// This avoids potential parameter type inference issues with Bun's SQL
|
|
809
|
-
entities = await dbConn
|
|
841
|
+
entities = await timedUnsafe<any[]>(dbConn, result.sql, result.params, this.execSignal, this.execPerRequest);
|
|
810
842
|
} else {
|
|
811
843
|
// Check prepared statement cache for regular queries
|
|
812
844
|
const cacheKey = this.context.generateCacheKey();
|
|
813
845
|
const { statement, isHit } = await preparedStatementCache.getOrCreate(result.sql, cacheKey, dbConn);
|
|
814
|
-
entities = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
846
|
+
entities = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
|
|
815
847
|
}
|
|
816
848
|
|
|
817
849
|
// Convert to Entity objects
|
|
@@ -871,32 +903,32 @@ AND c.deleted_at IS NULL`;
|
|
|
871
903
|
// Single component type - use direct partition if available
|
|
872
904
|
const partitionTableName = ComponentRegistry.getPartitionTableName(componentTypeIds[0]!);
|
|
873
905
|
if (partitionTableName) {
|
|
874
|
-
components = await dbConn
|
|
906
|
+
components = await timedUnsafe<any[]>(dbConn, `
|
|
875
907
|
SELECT id, entity_id, type_id, data
|
|
876
908
|
FROM ${partitionTableName}
|
|
877
909
|
WHERE entity_id IN ${entityIdList.sql}
|
|
878
910
|
AND type_id IN ${typeIdList.sql}
|
|
879
911
|
AND deleted_at IS NULL
|
|
880
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
912
|
+
`, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
|
|
881
913
|
} else {
|
|
882
914
|
// Fallback to parent table
|
|
883
|
-
components = await dbConn
|
|
915
|
+
components = await timedUnsafe<any[]>(dbConn, `
|
|
884
916
|
SELECT id, entity_id, type_id, data
|
|
885
917
|
FROM components
|
|
886
918
|
WHERE entity_id IN ${entityIdList.sql}
|
|
887
919
|
AND type_id IN ${typeIdList.sql}
|
|
888
920
|
AND deleted_at IS NULL
|
|
889
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
921
|
+
`, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
|
|
890
922
|
}
|
|
891
923
|
} else {
|
|
892
924
|
// Multiple types or direct partition disabled - use parent table
|
|
893
|
-
components = await dbConn
|
|
925
|
+
components = await timedUnsafe<any[]>(dbConn, `
|
|
894
926
|
SELECT id, entity_id, type_id, data
|
|
895
927
|
FROM components
|
|
896
928
|
WHERE entity_id IN ${entityIdList.sql}
|
|
897
929
|
AND type_id IN ${typeIdList.sql}
|
|
898
930
|
AND deleted_at IS NULL
|
|
899
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
931
|
+
`, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
|
|
900
932
|
}
|
|
901
933
|
|
|
902
934
|
// Get metadata storage for Date deserialization
|
|
@@ -945,7 +977,8 @@ AND c.deleted_at IS NULL`;
|
|
|
945
977
|
* Execute query with EXPLAIN ANALYZE for performance debugging
|
|
946
978
|
* Returns the query plan and execution statistics
|
|
947
979
|
*/
|
|
948
|
-
public async explainAnalyze(buffers: boolean = true): Promise<string> {
|
|
980
|
+
public async explainAnalyze(buffers: boolean = true, opts?: QueryExecOptions): Promise<string> {
|
|
981
|
+
this.applyExecOptions(opts);
|
|
949
982
|
// Reset context for fresh execution
|
|
950
983
|
this.context.reset();
|
|
951
984
|
|
|
@@ -993,7 +1026,7 @@ AND c.deleted_at IS NULL`;
|
|
|
993
1026
|
}
|
|
994
1027
|
|
|
995
1028
|
// Execute the EXPLAIN ANALYZE query
|
|
996
|
-
const explainResult = await dbConn
|
|
1029
|
+
const explainResult = await timedUnsafe<any[]>(dbConn, explainSql, result.params, this.execSignal, this.execPerRequest);
|
|
997
1030
|
|
|
998
1031
|
// Format the result
|
|
999
1032
|
return explainResult.map((row: any) => row['QUERY PLAN']).join('\n');
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for `createRequestLoaders` AbortSignal threading.
|
|
3
|
+
*
|
|
4
|
+
* The GraphQL request plugin (core/RequestContext.ts) wires the request's
|
|
5
|
+
* AbortSignal into each DataLoader's `db.unsafe()` call via timedUnsafe.
|
|
6
|
+
* On abort the in-flight query is cancelled, releasing the backend
|
|
7
|
+
* connection back to pgbouncer.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, beforeAll, beforeEach } from 'bun:test';
|
|
10
|
+
import db from '../../../database';
|
|
11
|
+
import { Entity } from '../../../core/Entity';
|
|
12
|
+
import { createRequestLoaders } from '../../../core/RequestLoaders';
|
|
13
|
+
import { ComponentRegistry } from '../../../core/components';
|
|
14
|
+
import { TestUser } from '../../fixtures/components';
|
|
15
|
+
import { createTestContext, ensureComponentsRegistered } from '../../utils';
|
|
16
|
+
|
|
17
|
+
describe('RequestLoaders AbortSignal', () => {
|
|
18
|
+
const ctx = createTestContext();
|
|
19
|
+
let seededEntity: Entity;
|
|
20
|
+
let testUserTypeId: string;
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
await ensureComponentsRegistered(TestUser);
|
|
24
|
+
testUserTypeId = ComponentRegistry.getComponentId('TestUser')!;
|
|
25
|
+
expect(testUserTypeId).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
// Fresh seed per test — createTestContext's afterEach cleans tracked
|
|
30
|
+
// entities, so seeds set up once would disappear after the first
|
|
31
|
+
// test in the suite runs.
|
|
32
|
+
seededEntity = ctx.tracker.create();
|
|
33
|
+
seededEntity.add(TestUser, { name: 'loader-seed', email: 'l@e.com', age: 1 });
|
|
34
|
+
await seededEntity.save();
|
|
35
|
+
await Entity.drainPendingSideEffects();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('entityById loader rejects when pre-aborted', async () => {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
controller.abort(new Error('pre-aborted'));
|
|
41
|
+
|
|
42
|
+
const loaders = createRequestLoaders(db, undefined, controller.signal);
|
|
43
|
+
await expect(loaders.entityById.load(seededEntity.id)).rejects.toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('componentsByEntityType loader rejects when pre-aborted', async () => {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
controller.abort(new Error('pre-aborted'));
|
|
49
|
+
|
|
50
|
+
const loaders = createRequestLoaders(db, undefined, controller.signal);
|
|
51
|
+
await expect(
|
|
52
|
+
loaders.componentsByEntityType.load({
|
|
53
|
+
entityId: seededEntity.id,
|
|
54
|
+
typeId: testUserTypeId,
|
|
55
|
+
}),
|
|
56
|
+
).rejects.toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('loaders without signal still work (backwards compatible)', async () => {
|
|
60
|
+
const loaders = createRequestLoaders(db);
|
|
61
|
+
const ent = await loaders.entityById.load(seededEntity.id);
|
|
62
|
+
expect(ent?.id).toBe(seededEntity.id);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('perRequest counters track DataLoader invocations', async () => {
|
|
66
|
+
const perRequest = {
|
|
67
|
+
dbQueryCount: 0,
|
|
68
|
+
dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
|
|
69
|
+
};
|
|
70
|
+
const loaders = createRequestLoaders(db, undefined, undefined, perRequest);
|
|
71
|
+
|
|
72
|
+
await loaders.entityById.load(seededEntity.id);
|
|
73
|
+
await loaders.componentsByEntityType.load({
|
|
74
|
+
entityId: seededEntity.id,
|
|
75
|
+
typeId: testUserTypeId,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(perRequest.dataLoaderCalls.entity).toBeGreaterThanOrEqual(1);
|
|
79
|
+
expect(perRequest.dataLoaderCalls.component).toBeGreaterThanOrEqual(1);
|
|
80
|
+
expect(perRequest.dbQueryCount).toBeGreaterThan(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for Query.exec / Query.count AbortSignal propagation.
|
|
3
|
+
*
|
|
4
|
+
* The framework wall-clock timeout (core/app/requestRouter.ts) aborts a
|
|
5
|
+
* controller on 30s. The plugin in core/RequestContext.ts threads the
|
|
6
|
+
* request's AbortSignal into Query.exec via `{ signal }` options. These
|
|
7
|
+
* tests prove the abort actually cancels the underlying Bun SQL query
|
|
8
|
+
* (releasing the pgbouncer-backed connection) rather than just rejecting
|
|
9
|
+
* the outer promise.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect, beforeAll } from 'bun:test';
|
|
12
|
+
import { Query } from '../../../query/Query';
|
|
13
|
+
import { TestUser } from '../../fixtures/components';
|
|
14
|
+
import { createTestContext, ensureComponentsRegistered } from '../../utils';
|
|
15
|
+
|
|
16
|
+
describe('Query AbortSignal propagation', () => {
|
|
17
|
+
const ctx = createTestContext();
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
await ensureComponentsRegistered(TestUser);
|
|
21
|
+
|
|
22
|
+
// Seed a small dataset so queries actually hit the DB.
|
|
23
|
+
for (let i = 0; i < 5; i++) {
|
|
24
|
+
const e = ctx.tracker.create();
|
|
25
|
+
e.add(TestUser, { name: `abort-seed-${i}`, email: `a${i}@e.com`, age: i });
|
|
26
|
+
await e.save();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('exec() with pre-aborted signal rejects without running query', async () => {
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
controller.abort(new Error('pre-aborted'));
|
|
33
|
+
|
|
34
|
+
const promise = new Query().with(TestUser).exec({ signal: controller.signal });
|
|
35
|
+
await expect(promise).rejects.toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('exec() rejects when signal aborts mid-flight', async () => {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
queueMicrotask(() => controller.abort(new Error('mid-flight')));
|
|
41
|
+
|
|
42
|
+
const promise = new Query().with(TestUser).exec({ signal: controller.signal });
|
|
43
|
+
await expect(promise).rejects.toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('exec() without signal still works (backwards compatible)', async () => {
|
|
47
|
+
const rows = await new Query().with(TestUser).take(5).exec();
|
|
48
|
+
expect(Array.isArray(rows)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('count() respects signal abort', async () => {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
controller.abort(new Error('pre-aborted'));
|
|
54
|
+
|
|
55
|
+
const promise = new Query().with(TestUser).count({ signal: controller.signal });
|
|
56
|
+
await expect(promise).rejects.toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('perRequest counters increment when supplied', async () => {
|
|
60
|
+
const perRequest = { dbQueryCount: 0 };
|
|
61
|
+
await new Query().with(TestUser).take(5).exec({ perRequest });
|
|
62
|
+
// Exec performs at least one DB query (count guard / select). Real
|
|
63
|
+
// count depends on prepared-cache state; assert non-zero only.
|
|
64
|
+
expect(perRequest.dbQueryCount).toBeGreaterThan(0);
|
|
65
|
+
});
|
|
66
|
+
});
|