bunsane 0.3.0 → 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 +104 -0
- package/CLAUDE.md +20 -0
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1060
- package/core/ArcheType.ts +78 -2110
- package/core/Entity.ts +136 -41
- package/core/RequestContext.ts +85 -36
- package/core/RequestLoaders.ts +89 -31
- package/core/SchedulerManager.ts +13 -13
- 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 +144 -9
- package/core/components/BaseComponent.ts +12 -2
- package/core/middleware/AccessLog.ts +8 -1
- package/database/PreparedStatementCache.ts +17 -16
- 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/ComponentInclusionNode.ts +5 -5
- package/query/Query.ts +65 -48
- package/service/ServiceRegistry.ts +7 -1
- package/service/index.ts +4 -2
- 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 +152 -1
- package/tests/unit/database/cancellable.test.ts +81 -0
- package/tests/unit/database/instrumentedDb.test.ts +160 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sessionId":"302022ef-825d-48c8-8ef6-656f1cd141e0","pid":60520,"procStart":"639139204827053470","acquiredAt":1778489386099}
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,110 @@ All notable changes to bunsane are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
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
|
+
identity — callers 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
|
+
|
|
7
111
|
### Fixed (PR E — outbox, cache, query hardening)
|
|
8
112
|
|
|
9
113
|
- **OutboxWorker publishes to Redis concurrently and marks rows in bulk.**
|
package/CLAUDE.md
CHANGED
|
@@ -159,6 +159,26 @@ The wrapper script:
|
|
|
159
159
|
- `?|` and `?&` operators not supported (use `@>` / `<@` instead)
|
|
160
160
|
- `CREATE INDEX CONCURRENTLY` not supported
|
|
161
161
|
- Single connection only (`POSTGRES_MAX_CONNECTIONS=1`)
|
|
162
|
+
- **Known Bun SQL + PGlite visibility race**: under a single-connection
|
|
163
|
+
pool with background work from a prior test file, `await entity.save()`
|
|
164
|
+
may resolve ≥1ms before the `INSERT INTO entity_components` row is
|
|
165
|
+
visible to a subsequent `db.unsafe('SELECT ...')` on the same driver.
|
|
166
|
+
The per-component partition INSERT (e.g. `components_<compname>`) in
|
|
167
|
+
the same transaction is visible immediately; only the flat
|
|
168
|
+
`entity_components` row lags. This causes multi-component Query
|
|
169
|
+
INTERSECTs to return 0 rows when run immediately after save.
|
|
170
|
+
|
|
171
|
+
**Mitigation (not a full fix):**
|
|
172
|
+
1. Shut down application-owned background workers (AI processors,
|
|
173
|
+
outbox workers, hook-driven save chains) in `afterAll` of each test
|
|
174
|
+
file — prevents cross-file bleed that amplifies the race.
|
|
175
|
+
2. Call `await Entity.drainPendingSideEffects()` in `beforeAll` / test
|
|
176
|
+
`setup` to settle hook-triggered post-commit work (helpful but
|
|
177
|
+
insufficient on its own).
|
|
178
|
+
3. Skip the affected test or poll with a short `setTimeout(1)`.
|
|
179
|
+
|
|
180
|
+
Root cause lives in the Bun SQL adapter's ACK handling, not bunsane.
|
|
181
|
+
File upstream with a minimal repro if you hit this.
|
|
162
182
|
|
|
163
183
|
## Directory Structure
|
|
164
184
|
|
package/config/cache.config.ts
CHANGED
|
@@ -31,6 +31,29 @@ export interface CacheConfig {
|
|
|
31
31
|
component?: {
|
|
32
32
|
enabled: boolean;
|
|
33
33
|
ttl: number;
|
|
34
|
+
/**
|
|
35
|
+
* Cache "absent" component lookups (negative cache) so repeated reads
|
|
36
|
+
* of optional components on the same entity do not re-hit the DB.
|
|
37
|
+
* Default false (opt-in) to preserve prior behavior.
|
|
38
|
+
*/
|
|
39
|
+
negativeCacheEnabled?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* TTL in ms for tombstone entries written for absent components.
|
|
42
|
+
* Defaults to min(component.ttl, 60_000). Keep ≤ ttl so a created
|
|
43
|
+
* row supersedes the tombstone within bounded staleness.
|
|
44
|
+
*/
|
|
45
|
+
negativeCacheTtl?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Negative-only cache for relation lookups (e.g. @HasMany returning []).
|
|
50
|
+
* Empty results are cached with a short TTL; positive results are not
|
|
51
|
+
* cached here (cross-entity invalidation is non-trivial — see RFC
|
|
52
|
+
* H-CACHE-NEG). Bounded staleness window equal to negativeCacheTtl.
|
|
53
|
+
*/
|
|
54
|
+
relation?: {
|
|
55
|
+
negativeCacheEnabled?: boolean;
|
|
56
|
+
negativeCacheTtl?: number;
|
|
34
57
|
};
|
|
35
58
|
|
|
36
59
|
query?: {
|
|
@@ -76,7 +99,18 @@ export const defaultCacheConfig: CacheConfig = {
|
|
|
76
99
|
|
|
77
100
|
component: {
|
|
78
101
|
enabled: process.env.CACHE_COMPONENT_ENABLED !== 'false', // Default true
|
|
79
|
-
ttl: parseInt(process.env.CACHE_COMPONENT_TTL || '1800000') // 30 minutes
|
|
102
|
+
ttl: parseInt(process.env.CACHE_COMPONENT_TTL || '1800000'), // 30 minutes
|
|
103
|
+
negativeCacheEnabled: process.env.CACHE_COMPONENT_NEGATIVE_ENABLED === 'true',
|
|
104
|
+
negativeCacheTtl: process.env.CACHE_COMPONENT_NEGATIVE_TTL
|
|
105
|
+
? parseInt(process.env.CACHE_COMPONENT_NEGATIVE_TTL)
|
|
106
|
+
: undefined
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
relation: {
|
|
110
|
+
negativeCacheEnabled: process.env.CACHE_RELATION_NEGATIVE_ENABLED === 'true',
|
|
111
|
+
negativeCacheTtl: process.env.CACHE_RELATION_NEGATIVE_TTL
|
|
112
|
+
? parseInt(process.env.CACHE_RELATION_NEGATIVE_TTL)
|
|
113
|
+
: 60_000
|
|
80
114
|
},
|
|
81
115
|
|
|
82
116
|
query: {
|