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.
Files changed (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +104 -0
  3. package/CLAUDE.md +20 -0
  4. package/config/cache.config.ts +35 -1
  5. package/core/App.ts +24 -1060
  6. package/core/ArcheType.ts +78 -2110
  7. package/core/Entity.ts +136 -41
  8. package/core/RequestContext.ts +85 -36
  9. package/core/RequestLoaders.ts +89 -31
  10. package/core/SchedulerManager.ts +13 -13
  11. package/core/app/bootstrap.ts +133 -0
  12. package/core/app/cors.ts +94 -0
  13. package/core/app/graphqlSetup.ts +56 -0
  14. package/core/app/healthEndpoints.ts +31 -0
  15. package/core/app/metricsCollector.ts +27 -0
  16. package/core/app/preparedStatementWarmup.ts +55 -0
  17. package/core/app/processHandlers.ts +43 -0
  18. package/core/app/requestRouter.ts +309 -0
  19. package/core/app/restRegistry.ts +72 -0
  20. package/core/app/shutdown.ts +97 -0
  21. package/core/app/studioRouter.ts +83 -0
  22. package/core/archetype/customTypes.ts +100 -0
  23. package/core/archetype/decorators.ts +171 -0
  24. package/core/archetype/fieldResolvers.ts +621 -0
  25. package/core/archetype/helpers.ts +29 -0
  26. package/core/archetype/relationLoader.ts +118 -0
  27. package/core/archetype/schemaBuilder.ts +141 -0
  28. package/core/archetype/weaver.ts +218 -0
  29. package/core/archetype/zodSchemaBuilder.ts +527 -0
  30. package/core/cache/CacheManager.ts +144 -9
  31. package/core/components/BaseComponent.ts +12 -2
  32. package/core/middleware/AccessLog.ts +8 -1
  33. package/database/PreparedStatementCache.ts +17 -16
  34. package/database/cancellable.ts +22 -0
  35. package/database/instrumentedDb.ts +141 -0
  36. package/docs/RFC_APP_REFACTOR.md +248 -0
  37. package/docs/RFC_REFACTOR_TARGETS.md +251 -0
  38. package/package.json +1 -1
  39. package/query/ComponentInclusionNode.ts +5 -5
  40. package/query/Query.ts +65 -48
  41. package/service/ServiceRegistry.ts +7 -1
  42. package/service/index.ts +4 -2
  43. package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
  44. package/tests/integration/query/Query.abort.test.ts +66 -0
  45. package/tests/unit/cache/CacheManager.test.ts +152 -1
  46. package/tests/unit/database/cancellable.test.ts +81 -0
  47. package/tests/unit/database/instrumentedDb.test.ts +160 -0
  48. package/tests/unit/entity/Entity.components.test.ts +73 -0
  49. package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
  50. package/tests/unit/entity/Entity.reload.test.ts +63 -0
  51. package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
  52. package/tests/unit/query/Query.emptyString.test.ts +69 -0
  53. package/tests/unit/query/Query.test.ts +6 -4
  54. 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
 
@@ -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: {