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.
Files changed (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
package/query/Query.ts CHANGED
@@ -8,11 +8,18 @@ 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";
14
15
  import type { ComponentConstructor, TypedEntity, ComponentRecord } from "../types/query.types";
15
16
  import { assertComponentTableName, assertFieldPath } from "./SqlIdentifier";
17
+ import { getMembershipSource } from "./membershipSource";
18
+
19
+ // Parsed once at module load instead of on every exec() (process.env read +
20
+ // parseInt was on the query hot path). 0 disables the default limit.
21
+ const DEFAULT_QUERY_LIMIT = parseInt(process.env.BUNSANE_DEFAULT_QUERY_LIMIT ?? '10000', 10);
22
+ let warnedDefaultLimit = false;
16
23
 
17
24
  export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "ILIKE" | "IN" | "NOT IN" | string;
18
25
 
@@ -62,6 +69,21 @@ export interface QueryCacheOptions {
62
69
  component?: boolean;
63
70
  }
64
71
 
72
+ /**
73
+ * Options accepted by Query terminal methods (`exec`, `count`, `sum`, etc.).
74
+ * - `signal` cancels in-flight DB queries via Bun's `Query.cancel()` when
75
+ * fired. The request-scoped signal from `req.signal` is automatically
76
+ * threaded into resolver-level Query instances by the framework's
77
+ * GraphQL request context plugin; manual callers pass it explicitly.
78
+ * - `perRequest` is an opaque counter object incremented by the
79
+ * instrumented DB layer so per-request stats (dbQueryCount,
80
+ * dataLoaderCalls) are reported on access/timeout logs.
81
+ */
82
+ export interface QueryExecOptions {
83
+ signal?: AbortSignal;
84
+ perRequest?: PerRequestCounters;
85
+ }
86
+
65
87
  /**
66
88
  * New Query class that uses DAG internally for better modularity and extensibility.
67
89
  *
@@ -85,6 +107,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
85
107
  private trx: SQL | undefined;
86
108
  private skipPreparedCache: boolean = false;
87
109
  private skipComponentCache: boolean = false;
110
+ private execSignal?: AbortSignal;
111
+ private execPerRequest?: PerRequestCounters;
88
112
 
89
113
  /** Component constructors added to this query for type-safe access */
90
114
  private _componentCtors: ComponentConstructor[] = [];
@@ -110,12 +134,12 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
110
134
  return this;
111
135
  }
112
136
 
113
- public async findOneById(id: string): Promise<TypedEntity<TComponents> | null> {
137
+ public async findOneById(id: string, opts?: QueryExecOptions): Promise<TypedEntity<TComponents> | null> {
114
138
  // Validate ID to prevent PostgreSQL UUID parsing errors
115
139
  if (!id || typeof id !== 'string' || id.trim() === '') {
116
140
  return null;
117
141
  }
118
- const entities = await this.findById(id).exec();
142
+ const entities = await this.findById(id).exec(opts);
119
143
  return entities.length > 0 ? entities[0]! : null;
120
144
  }
121
145
 
@@ -136,6 +160,9 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
136
160
  if (componentCtorOrComponentsOrOrQuery instanceof OrQuery) {
137
161
  // Handle OR query
138
162
  this.orQuery = componentCtorOrComponentsOrOrQuery;
163
+ // Suppress base-level scan optimizations that bake ORDER/LIMIT
164
+ // into the SQL OrNode later embeds as its base set.
165
+ this.context.hasOrQuery = true;
139
166
  return this;
140
167
  }
141
168
 
@@ -282,6 +309,9 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
282
309
  /**
283
310
  * Bypass cache for this query.
284
311
  * @param options Cache options to bypass. If not provided, bypasses prepared statement cache.
312
+ * Note: the prepared-statement option is now a no-op (queries always
313
+ * execute directly; Bun SQL handles statement preparation). The
314
+ * `component` option still controls the component cache.
285
315
  */
286
316
  public noCache(): this;
287
317
  public noCache(options: QueryCacheOptions): this;
@@ -300,12 +330,14 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
300
330
  return this;
301
331
  }
302
332
 
303
- public count(): Promise<number> {
333
+ public count(opts?: QueryExecOptions): Promise<number> {
334
+ this.applyExecOptions(opts);
304
335
  return new Promise<number>((resolve, reject) => {
305
336
  const timeout = setTimeout(() => {
306
337
  logger.error(`Query count execution timeout`);
307
338
  reject(new Error(`Query count execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
308
339
  }, QUERY_TIMEOUT_MS);
340
+ (timeout as unknown as { unref?: () => void }).unref?.();
309
341
  this.doCount()
310
342
  .then(result => {
311
343
  clearTimeout(timeout);
@@ -318,6 +350,17 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
318
350
  });
319
351
  }
320
352
 
353
+ /**
354
+ * Apply terminal-method options to instance fields so internal helpers
355
+ * (doCount, doExec, populateComponents, doAggregate, …) can read them
356
+ * without threading parameters through every private method.
357
+ */
358
+ private applyExecOptions(opts?: QueryExecOptions): void {
359
+ if (!opts) return;
360
+ if (opts.signal !== undefined) this.execSignal = opts.signal;
361
+ if (opts.perRequest !== undefined) this.execPerRequest = opts.perRequest;
362
+ }
363
+
321
364
  /**
322
365
  * Get an estimated count using PostgreSQL statistics.
323
366
  * Much faster than exact count() for large tables - O(1) instead of O(n).
@@ -333,7 +376,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
333
376
  * const approxCount = await new Query().with(User).estimatedCount(User);
334
377
  * console.log(`Approximately ${approxCount} users`);
335
378
  */
336
- public async estimatedCount(component: new (...args: any[]) => BaseComponent): Promise<number> {
379
+ public async estimatedCount(component: new (...args: any[]) => BaseComponent, opts?: QueryExecOptions): Promise<number> {
380
+ this.applyExecOptions(opts);
337
381
  const typeId = ComponentRegistry.getComponentId(component.name);
338
382
  if (!typeId) {
339
383
  throw new Error(`Component ${component.name} not registered`);
@@ -349,12 +393,32 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
349
393
  const dbConn = this.getDb();
350
394
 
351
395
  // Use PostgreSQL's statistics for fast count estimate
352
- // This queries pg_class which is O(1) instead of scanning the table
353
- const sql = tableName && tableName !== 'components'
354
- ? `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`
355
- : `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'entity_components'`;
396
+ // This queries pg_class which is O(1) instead of scanning the table.
397
+ // When the component resolves to a specific partition table, read its
398
+ // reltuples directly. Otherwise fall back to the membership source:
399
+ // legacy reads `entity_components` reltuples; the components source
400
+ // sums the LIST-partition child stats (the partitioned parent's
401
+ // reltuples is unreliable).
402
+ let sql: string;
403
+ let params: any[];
404
+ if (tableName && tableName !== 'components') {
405
+ sql = `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`;
406
+ params = [tableName];
407
+ } else if (getMembershipSource().isLegacy) {
408
+ sql = `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'entity_components'`;
409
+ params = [];
410
+ } else {
411
+ // No COALESCE: an empty partition set must yield NULL so the
412
+ // exact-count fallback below triggers, matching the legacy
413
+ // zero-rows behavior.
414
+ sql = `SELECT SUM(c.reltuples)::bigint AS estimate
415
+ FROM pg_class c
416
+ JOIN pg_inherits i ON c.oid = i.inhrelid
417
+ WHERE i.inhparent = 'components'::regclass`;
418
+ params = [];
419
+ }
356
420
 
357
- const result = await dbConn.unsafe(sql, [tableName || 'entity_components']);
421
+ const result = await timedUnsafe<any[]>(dbConn, sql, params, this.execSignal, this.execPerRequest);
358
422
 
359
423
  if (!result || result.length === 0 || result[0].estimate === null) {
360
424
  // Fallback to exact count if statistics not available
@@ -365,6 +429,32 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
365
429
  }
366
430
 
367
431
  private async doCount(): Promise<number> {
432
+ // Fresh params for re-execution. doExec/doAggregate already reset;
433
+ // missing here meant stale params (wrong bindings) on Query reuse.
434
+ this.context.reset();
435
+
436
+ // count() must return total matching cardinality. Pagination and
437
+ // sort must not leak into the counted subquery — a LIMIT inside the
438
+ // subquery caps the count (after a prior exec() the framework
439
+ // default LIMIT silently capped every count at
440
+ // BUNSANE_DEFAULT_QUERY_LIMIT), and ORDER BY is wasted work under
441
+ // COUNT(*). Save/restore so exec() after count() behaves unchanged.
442
+ const savedLimit = this.context.limit;
443
+ const savedOffset = this.context.offsetValue;
444
+ const savedSorts = this.context.sortOrders;
445
+ this.context.limit = null;
446
+ this.context.offsetValue = 0;
447
+ this.context.sortOrders = [];
448
+ try {
449
+ return await this.doCountInner();
450
+ } finally {
451
+ this.context.limit = savedLimit;
452
+ this.context.offsetValue = savedOffset;
453
+ this.context.sortOrders = savedSorts;
454
+ }
455
+ }
456
+
457
+ private async doCountInner(): Promise<number> {
368
458
  // Build the DAG
369
459
  const dag = new QueryDAG();
370
460
 
@@ -406,18 +496,11 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
406
496
  // Get the database connection (transaction or default)
407
497
  const dbConn = this.getDb();
408
498
 
409
- let countResult: any[];
410
-
411
- if (this.skipPreparedCache) {
412
- // Bypass cache - execute directly
413
- countResult = await dbConn.unsafe(countSql, result.params);
414
- } else {
415
- // Check prepared statement cache
416
- // Add 'count:' prefix to differentiate count queries from exec queries
417
- const cacheKey = 'count:' + this.context.generateCacheKey();
418
- const { statement, isHit } = await preparedStatementCache.getOrCreate(countSql, cacheKey, dbConn);
419
- countResult = await preparedStatementCache.execute(statement, result.params, dbConn);
420
- }
499
+ // Execute directly. Bun SQL auto-prepares parameterized statements
500
+ // per connection (prepare:true default) — the former framework-level
501
+ // "prepared statement cache" never called a prepare API and only
502
+ // added cache-key string building on the hot path.
503
+ const countResult: any[] = await timedUnsafe<any[]>(dbConn, countSql, result.params, this.execSignal, this.execPerRequest);
421
504
 
422
505
  // Debug logging
423
506
  if (this.debug) {
@@ -465,6 +548,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
465
548
  logger.error(`Query sum execution timeout`);
466
549
  reject(new Error(`Query sum execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
467
550
  }, QUERY_TIMEOUT_MS);
551
+ (timeout as unknown as { unref?: () => void }).unref?.();
468
552
  this.doAggregate('SUM', componentCtor, field as string)
469
553
  .then(result => {
470
554
  clearTimeout(timeout);
@@ -493,6 +577,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
493
577
  logger.error(`Query average execution timeout`);
494
578
  reject(new Error(`Query average execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
495
579
  }, QUERY_TIMEOUT_MS);
580
+ (timeout as unknown as { unref?: () => void }).unref?.();
496
581
  this.doAggregate('AVG', componentCtor, field as string)
497
582
  .then(result => {
498
583
  clearTimeout(timeout);
@@ -600,15 +685,9 @@ AND c.deleted_at IS NULL`;
600
685
  // Get the database connection
601
686
  const dbConn = this.getDb();
602
687
 
603
- let aggregateResult: any[];
604
-
605
- if (this.skipPreparedCache) {
606
- aggregateResult = await dbConn.unsafe(aggregateSql, result.params);
607
- } else {
608
- const cacheKey = `${aggregateType.toLowerCase()}:${typeId}:${field}:` + this.context.generateCacheKey();
609
- const { statement } = await preparedStatementCache.getOrCreate(aggregateSql, cacheKey, dbConn);
610
- aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn);
611
- }
688
+ // Direct execution — see doCountInner for why the framework-level
689
+ // prepared statement cache was removed from the hot path.
690
+ const aggregateResult: any[] = await timedUnsafe<any[]>(dbConn, aggregateSql, result.params, this.execSignal, this.execPerRequest);
612
691
 
613
692
  // Debug logging
614
693
  if (this.debug) {
@@ -645,17 +724,22 @@ AND c.deleted_at IS NULL`;
645
724
  * @returns Promise resolving to array of TypedEntity with accumulated component types
646
725
  */
647
726
  @timed("Query.exec")
648
- public async exec(): Promise<TypedEntity<TComponents>[]> {
727
+ public async exec(opts?: QueryExecOptions): Promise<TypedEntity<TComponents>[]> {
728
+ this.applyExecOptions(opts);
649
729
  // Apply default LIMIT so unbounded queries cannot load entire tables
650
730
  // into memory. Configurable via BUNSANE_DEFAULT_QUERY_LIMIT, 0 to
651
731
  // disable. When the default is applied without an explicit .take(),
652
732
  // warn once at execution so developers notice runaway queries
653
733
  // (H-QUERY-1).
654
734
  if (this.context.limit === null || this.context.limit === undefined) {
655
- const envLimit = parseInt(process.env.BUNSANE_DEFAULT_QUERY_LIMIT ?? '10000', 10);
656
- if (envLimit > 0) {
657
- this.context.limit = envLimit;
658
- logger.warn({ scope: 'Query.exec', defaultLimit: envLimit }, 'Query executed without explicit .take() — applying framework default LIMIT. Call .take(N) to suppress this warning.');
735
+ if (DEFAULT_QUERY_LIMIT > 0) {
736
+ this.context.limit = DEFAULT_QUERY_LIMIT;
737
+ // Warn once per process — this fires on every unbounded query,
738
+ // so logging per-call floods logs and allocates on the hot path.
739
+ if (!warnedDefaultLimit) {
740
+ warnedDefaultLimit = true;
741
+ logger.warn({ scope: 'Query.exec', defaultLimit: DEFAULT_QUERY_LIMIT }, 'Query executed without explicit .take() — applying framework default LIMIT. Call .take(N) to suppress this warning.');
742
+ }
659
743
  }
660
744
  }
661
745
 
@@ -665,6 +749,9 @@ AND c.deleted_at IS NULL`;
665
749
  logger.error(`Query execution timeout`);
666
750
  reject(new Error(`Query execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
667
751
  }, QUERY_TIMEOUT_MS); // 30 second timeout
752
+ // unref: at high QPS thousands of these are live concurrently;
753
+ // they must not hold the event loop open nor add ref'd-timer churn.
754
+ (timeout as unknown as { unref?: () => void }).unref?.();
668
755
 
669
756
  this.doExec()
670
757
  .then(result => {
@@ -801,18 +888,13 @@ AND c.deleted_at IS NULL`;
801
888
  }
802
889
  }
803
890
 
804
- let entities: any[];
805
-
806
- if (this.orQuery || this.skipPreparedCache) {
807
- // For OR queries or explicit cache bypass, execute directly
808
- // This avoids potential parameter type inference issues with Bun's SQL
809
- entities = await dbConn.unsafe(result.sql, result.params);
810
- } else {
811
- // Check prepared statement cache for regular queries
812
- const cacheKey = this.context.generateCacheKey();
813
- const { statement, isHit } = await preparedStatementCache.getOrCreate(result.sql, cacheKey, dbConn);
814
- entities = await preparedStatementCache.execute(statement, result.params, dbConn);
815
- }
891
+ // Execute directly. Bun SQL auto-prepares parameterized statements
892
+ // per connection (prepare:true default), so server-side plan reuse
893
+ // already happens at the driver layer. The former framework-level
894
+ // "prepared statement cache" stored a placeholder object and
895
+ // re-executed db.unsafe anyway pure cache-key/bookkeeping overhead
896
+ // on every exec.
897
+ const entities: any[] = await timedUnsafe<any[]>(dbConn, result.sql, result.params, this.execSignal, this.execPerRequest);
816
898
 
817
899
  // Convert to Entity objects
818
900
  const entityIds: string[] = entities.map((row: any) => row.id);
@@ -866,37 +948,39 @@ AND c.deleted_at IS NULL`;
866
948
  // Get the database connection (transaction or default)
867
949
  const dbConn = this.getDb();
868
950
 
951
+ // created_at/updated_at included so results can warm the component
952
+ // cache below with full ComponentData entries.
869
953
  let components: any[];
870
954
  if (shouldUseDirectPartition() && componentTypeIds.length === 1) {
871
955
  // Single component type - use direct partition if available
872
956
  const partitionTableName = ComponentRegistry.getPartitionTableName(componentTypeIds[0]!);
873
957
  if (partitionTableName) {
874
- components = await dbConn.unsafe(`
875
- SELECT id, entity_id, type_id, data
958
+ components = await timedUnsafe<any[]>(dbConn, `
959
+ SELECT id, entity_id, type_id, data, created_at, updated_at
876
960
  FROM ${partitionTableName}
877
961
  WHERE entity_id IN ${entityIdList.sql}
878
962
  AND type_id IN ${typeIdList.sql}
879
963
  AND deleted_at IS NULL
880
- `, [...entityIdList.params, ...typeIdList.params]);
964
+ `, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
881
965
  } else {
882
966
  // Fallback to parent table
883
- components = await dbConn.unsafe(`
884
- SELECT id, entity_id, type_id, data
967
+ components = await timedUnsafe<any[]>(dbConn, `
968
+ SELECT id, entity_id, type_id, data, created_at, updated_at
885
969
  FROM components
886
970
  WHERE entity_id IN ${entityIdList.sql}
887
971
  AND type_id IN ${typeIdList.sql}
888
972
  AND deleted_at IS NULL
889
- `, [...entityIdList.params, ...typeIdList.params]);
973
+ `, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
890
974
  }
891
975
  } else {
892
976
  // Multiple types or direct partition disabled - use parent table
893
- components = await dbConn.unsafe(`
894
- SELECT id, entity_id, type_id, data
977
+ components = await timedUnsafe<any[]>(dbConn, `
978
+ SELECT id, entity_id, type_id, data, created_at, updated_at
895
979
  FROM components
896
980
  WHERE entity_id IN ${entityIdList.sql}
897
981
  AND type_id IN ${typeIdList.sql}
898
982
  AND deleted_at IS NULL
899
- `, [...entityIdList.params, ...typeIdList.params]);
983
+ `, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
900
984
  }
901
985
 
902
986
  // Get metadata storage for Date deserialization
@@ -939,13 +1023,70 @@ AND c.deleted_at IS NULL`;
939
1023
  // Add component to entity (using protected method)
940
1024
  (entity as any).addComponent(component);
941
1025
  }
1026
+
1027
+ this.warmComponentCache(components, entityIds, componentTypeIds);
1028
+ }
1029
+
1030
+ /**
1031
+ * Fire-and-forget warm of the L1/L2 component cache from populate()
1032
+ * results, so subsequent `entity.get(X)` calls (same or later request)
1033
+ * hit cache instead of re-querying. Previously populate() bypassed the
1034
+ * cache entirely — only the DataLoader read path warmed it.
1035
+ *
1036
+ * Tracked via Entity.trackCacheOp so shutdown/tests can drain it.
1037
+ * Skipped for large result sets to avoid hammering the cache provider
1038
+ * with bulk-scan output.
1039
+ */
1040
+ private warmComponentCache(components: any[], entityIds: string[], componentTypeIds: string[]): void {
1041
+ const WARM_CACHE_MAX = 1000;
1042
+ if (this.skipComponentCache || this.trx) return;
1043
+ if (components.length === 0 || components.length > WARM_CACHE_MAX) return;
1044
+
1045
+ Entity.trackCacheOp((async () => {
1046
+ try {
1047
+ const { CacheManager } = await import('../core/cache/CacheManager');
1048
+ const cacheManager = CacheManager.getInstance();
1049
+ const config = cacheManager.getConfig();
1050
+ if (!config.enabled || !config.component?.enabled) return;
1051
+
1052
+ // Requested (entity × type) pairs let the cache tombstone
1053
+ // known-absent components. Only built when the pair count is
1054
+ // bounded — tombstoning a huge scan is not worth the writes.
1055
+ let requested: Array<{ entityId: string; typeId: string }> | undefined;
1056
+ if (entityIds.length * componentTypeIds.length <= WARM_CACHE_MAX) {
1057
+ requested = [];
1058
+ for (const entityId of entityIds) {
1059
+ for (const typeId of componentTypeIds) {
1060
+ requested.push({ entityId, typeId });
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ await cacheManager.setComponentsWriteThrough(
1066
+ components.map((row: any) => ({
1067
+ id: row.id,
1068
+ entityId: row.entity_id,
1069
+ typeId: row.type_id,
1070
+ data: row.data,
1071
+ createdAt: row.created_at,
1072
+ updatedAt: row.updated_at,
1073
+ deletedAt: null,
1074
+ })),
1075
+ requested,
1076
+ config.component.ttl,
1077
+ );
1078
+ } catch (error) {
1079
+ logger.warn({ scope: 'cache', component: 'Query', msg: 'populate() component cache warm failed', error });
1080
+ }
1081
+ })());
942
1082
  }
943
1083
 
944
1084
  /**
945
1085
  * Execute query with EXPLAIN ANALYZE for performance debugging
946
1086
  * Returns the query plan and execution statistics
947
1087
  */
948
- public async explainAnalyze(buffers: boolean = true): Promise<string> {
1088
+ public async explainAnalyze(buffers: boolean = true, opts?: QueryExecOptions): Promise<string> {
1089
+ this.applyExecOptions(opts);
949
1090
  // Reset context for fresh execution
950
1091
  this.context.reset();
951
1092
 
@@ -993,14 +1134,17 @@ AND c.deleted_at IS NULL`;
993
1134
  }
994
1135
 
995
1136
  // Execute the EXPLAIN ANALYZE query
996
- const explainResult = await dbConn.unsafe(explainSql, result.params);
1137
+ const explainResult = await timedUnsafe<any[]>(dbConn, explainSql, result.params, this.execSignal, this.execPerRequest);
997
1138
 
998
1139
  // Format the result
999
1140
  return explainResult.map((row: any) => row['QUERY PLAN']).join('\n');
1000
1141
  }
1001
1142
 
1002
1143
  /**
1003
- * Get prepared statement cache statistics
1144
+ * Get prepared statement cache statistics.
1145
+ * @deprecated The framework-level prepared statement cache is no longer
1146
+ * used on the query hot path (Bun SQL auto-prepares at the driver
1147
+ * layer). Stats remain for API compatibility and report an idle cache.
1004
1148
  */
1005
1149
  public static getCacheStats() {
1006
1150
  return preparedStatementCache.getStats();
@@ -36,6 +36,11 @@ export class QueryContext {
36
36
  public cteName: string = "";
37
37
  public eagerComponents: Set<string> = new Set();
38
38
  public paginationAppliedInCTE: boolean = false;
39
+ // Set by Query when an OrQuery participates. OrNode embeds its
40
+ // ComponentInclusionNode dependency's SQL as a base set, so base-level
41
+ // optimizations that bake in ORDER BY/LIMIT (sort-driven scan) must be
42
+ // suppressed.
43
+ public hasOrQuery: boolean = false;
39
44
 
40
45
  private trx: SQL | undefined;
41
46
  constructor(trx?: SQL) {
@@ -165,6 +170,7 @@ export class QueryContext {
165
170
  clone.cteName = this.cteName;
166
171
  clone.eagerComponents = new Set(this.eagerComponents);
167
172
  clone.paginationAppliedInCTE = this.paginationAppliedInCTE;
173
+ clone.hasOrQuery = this.hasOrQuery;
168
174
  return clone;
169
175
  }
170
176
  }
package/query/QueryDAG.ts CHANGED
@@ -90,8 +90,13 @@ export class QueryDAG {
90
90
  totalFilters += filters.length;
91
91
  }
92
92
 
93
- // If we have multiple component filters (>= 2), use CTE for optimization
94
- const useCTE = totalFilters >= 2 && context.componentIds.size > 0;
93
+ // If we have multiple component filters (>= 2), use CTE for optimization.
94
+ // Exception: sorted multi-component queries take the sort-driven scan
95
+ // in ComponentInclusionNode, which replaces both the CTE base set and
96
+ // the post-hoc sort — building the CTE first would add orphan params.
97
+ const useCTE = totalFilters >= 2
98
+ && context.componentIds.size > 0
99
+ && !ComponentInclusionNode.canUseSortDrivenScan(context);
95
100
 
96
101
  if (useCTE) {
97
102
  // Create CTE node as root
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Resolves the entity<->component membership source for query generation.
3
+ *
4
+ * Historically membership lived in the redundant `entity_components` junction
5
+ * table. The `components` table already encodes membership via
6
+ * `UNIQUE(entity_id, type_id)`, so reads can be redirected to it. This module
7
+ * gates that redirection behind `BUNSANE_MEMBERSHIP_SOURCE`:
8
+ *
9
+ * - `components` (default): read membership from `components`.
10
+ * - `legacy`: read from `entity_components` (instant rollback). Behavior is
11
+ * byte-identical to the pre-redirect query generation.
12
+ *
13
+ * The env var is read at call time (not module load) so tests can flip it.
14
+ *
15
+ * Phase 1 of docs/ENTITY_COMPONENTS_REMOVAL_PLAN.md.
16
+ */
17
+
18
+ import { assertComponentTableName, InvalidIdentifierError } from "./SqlIdentifier";
19
+
20
+ const LEGACY_TABLE = "entity_components";
21
+ const COMPONENTS_TABLE = "components";
22
+
23
+ export interface MembershipSource {
24
+ /**
25
+ * The table to scan for membership rows. Feeds raw SQL — already validated
26
+ * against the allow-list.
27
+ */
28
+ table: string;
29
+ /**
30
+ * Whether the legacy `component_id`-join style applies. When false, the
31
+ * membership rows live in `components` and joins to component data collapse
32
+ * to single-table predicates (`c.entity_id = ? AND c.type_id = ?`).
33
+ */
34
+ isLegacy: boolean;
35
+ }
36
+
37
+ /**
38
+ * Resolve the configured membership source. Reads `BUNSANE_MEMBERSHIP_SOURCE`
39
+ * at call time; defaults to `components`.
40
+ */
41
+ export function getMembershipSource(): MembershipSource {
42
+ const raw = (process.env.BUNSANE_MEMBERSHIP_SOURCE || COMPONENTS_TABLE).toLowerCase();
43
+ const isLegacy = raw === "legacy";
44
+ const table = isLegacy ? LEGACY_TABLE : COMPONENTS_TABLE;
45
+
46
+ // Defensive: both names are static, but they feed raw SQL — validate
47
+ // against an explicit allow-list of exactly the two known table names so a
48
+ // regression here cannot widen injection surface. `entity_components` does
49
+ // not match the `components*` component-table pattern, so it gets the
50
+ // explicit check; `components` is additionally run through the shared
51
+ // allow-list validator for consistency.
52
+ if (isLegacy) {
53
+ if (table !== LEGACY_TABLE) {
54
+ throw new InvalidIdentifierError("membershipSource.table", table);
55
+ }
56
+ } else {
57
+ assertComponentTableName(table, "membershipSource.table");
58
+ }
59
+
60
+ return { table, isLegacy };
61
+ }
62
+
63
+ /** Convenience: the membership table name only. */
64
+ export function getMembershipTable(): string {
65
+ return getMembershipSource().table;
66
+ }
@@ -24,12 +24,17 @@ export class LocalStorageProvider extends StorageProvider {
24
24
  this.basePath = config.basePath || "./public";
25
25
  this.baseUrl = config.baseUrl || "";
26
26
  this.validateConfig();
27
+ // Synchronous, idempotent dir creation. Done in the constructor so that
28
+ // registration is never deferred to a microtask — see BUNSANE-007.
29
+ this.ensureBaseDir();
27
30
  }
28
31
 
29
32
  public async initialize(): Promise<void> {
30
- logger.info("Initializing Local Storage Provider");
31
-
32
- // Ensure base directory exists
33
+ // Kept for StorageProvider contract / explicit re-init. Idempotent.
34
+ this.ensureBaseDir();
35
+ }
36
+
37
+ private ensureBaseDir(): void {
33
38
  if (!fs.existsSync(this.basePath)) {
34
39
  fs.mkdirSync(this.basePath, { recursive: true });
35
40
  logger.info(`Created base directory: ${this.basePath}`);