bunsane 0.3.2 → 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 (214) hide show
  1. package/CHANGELOG.md +445 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +85 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +4 -4
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +16 -8
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +364 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/pendingOps.ts +72 -0
  30. package/core/entity/saveEntity.ts +377 -0
  31. package/core/hooks/dispatcher.ts +439 -0
  32. package/core/hooks/guards.ts +155 -0
  33. package/core/hooks/registry.ts +247 -0
  34. package/core/metadata/definitions/Component.ts +1 -1
  35. package/core/metadata/index.ts +15 -4
  36. package/core/middleware/RateLimit.ts +102 -105
  37. package/core/middleware/RequestId.ts +2 -9
  38. package/core/middleware/SecurityHeaders.ts +2 -11
  39. package/core/middleware/headers.ts +28 -0
  40. package/core/remote/OutboxWorker.ts +213 -183
  41. package/core/remote/RemoteManager.ts +401 -400
  42. package/core/remote/types.ts +153 -151
  43. package/core/requestScope.ts +34 -0
  44. package/core/scheduler/cronEvaluator.ts +174 -0
  45. package/core/scheduler/lifecycleHooks.ts +21 -0
  46. package/core/scheduler/lockCoordinator.ts +27 -0
  47. package/core/scheduler/metrics.ts +14 -0
  48. package/core/scheduler/taskRunner.ts +420 -0
  49. package/database/DatabaseHelper.ts +128 -101
  50. package/database/IndexingStrategy.ts +72 -2
  51. package/database/PreparedStatementCache.ts +8 -2
  52. package/database/cancellable.ts +35 -22
  53. package/database/index.ts +15 -3
  54. package/database/instrumentedDb.ts +141 -141
  55. package/endpoints/archetypes.ts +2 -8
  56. package/endpoints/tables.ts +6 -1
  57. package/gql/index.ts +1 -1
  58. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  59. package/package.json +22 -1
  60. package/query/CTENode.ts +5 -3
  61. package/query/ComponentInclusionNode.ts +240 -13
  62. package/query/OrNode.ts +6 -5
  63. package/query/Query.ts +157 -46
  64. package/query/QueryContext.ts +6 -0
  65. package/query/QueryDAG.ts +7 -2
  66. package/query/membershipSource.ts +66 -0
  67. package/storage/LocalStorageProvider.ts +8 -3
  68. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  69. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  70. package/studio/{index.html → dist/index.html} +3 -2
  71. package/swagger/generator.ts +11 -1
  72. package/upload/UploadManager.ts +8 -6
  73. package/utils/uuid.ts +40 -10
  74. package/.claude/scheduled_tasks.lock +0 -1
  75. package/.claude/settings.local.json +0 -47
  76. package/.prettierrc +0 -4
  77. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  78. package/.serena/memories/architecture.md +0 -154
  79. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  80. package/.serena/memories/code_style_and_conventions.md +0 -76
  81. package/.serena/memories/project_overview.md +0 -43
  82. package/.serena/memories/schema-dsl-plan.md +0 -107
  83. package/.serena/memories/suggested_commands.md +0 -80
  84. package/.serena/memories/typescript-compilation-status.md +0 -54
  85. package/.serena/project.yml +0 -114
  86. package/BunSane.jpg +0 -0
  87. package/CLAUDE.md +0 -198
  88. package/TODO.md +0 -2
  89. package/bun.lock +0 -302
  90. package/bunfig.toml +0 -10
  91. package/docs/RFC_APP_REFACTOR.md +0 -248
  92. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  93. package/docs/SCALABILITY_PLAN.md +0 -175
  94. package/studio/bun.lock +0 -482
  95. package/studio/package.json +0 -39
  96. package/studio/postcss.config.js +0 -6
  97. package/studio/src/components/DataTable.tsx +0 -211
  98. package/studio/src/components/Layout.tsx +0 -13
  99. package/studio/src/components/PageContainer.tsx +0 -9
  100. package/studio/src/components/PageHeader.tsx +0 -13
  101. package/studio/src/components/SearchBar.tsx +0 -57
  102. package/studio/src/components/Sidebar.tsx +0 -294
  103. package/studio/src/components/ui/button.tsx +0 -56
  104. package/studio/src/components/ui/checkbox.tsx +0 -26
  105. package/studio/src/components/ui/input.tsx +0 -25
  106. package/studio/src/hooks/useDataTable.ts +0 -131
  107. package/studio/src/index.css +0 -36
  108. package/studio/src/lib/api.ts +0 -186
  109. package/studio/src/lib/utils.ts +0 -13
  110. package/studio/src/main.tsx +0 -17
  111. package/studio/src/pages/ArcheType.tsx +0 -239
  112. package/studio/src/pages/Components.tsx +0 -124
  113. package/studio/src/pages/EntityInspector.tsx +0 -302
  114. package/studio/src/pages/QueryRunner.tsx +0 -246
  115. package/studio/src/pages/Table.tsx +0 -94
  116. package/studio/src/pages/Welcome.tsx +0 -241
  117. package/studio/src/routes.tsx +0 -45
  118. package/studio/src/store/archeTypeSettings.ts +0 -30
  119. package/studio/src/store/studio.ts +0 -65
  120. package/studio/src/utils/columnHelpers.tsx +0 -114
  121. package/studio/studio-instructions.md +0 -81
  122. package/studio/tailwind.config.js +0 -77
  123. package/studio/utils.ts +0 -54
  124. package/studio/vite.config.js +0 -19
  125. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  126. package/tests/benchmark/bunfig.toml +0 -9
  127. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  128. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  129. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  130. package/tests/benchmark/fixtures/index.ts +0 -6
  131. package/tests/benchmark/index.ts +0 -22
  132. package/tests/benchmark/noop-preload.ts +0 -3
  133. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  134. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  135. package/tests/benchmark/runners/index.ts +0 -4
  136. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  137. package/tests/benchmark/scripts/generate-db.ts +0 -344
  138. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  139. package/tests/e2e/http.test.ts +0 -130
  140. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  141. package/tests/fixtures/components/TestOrder.ts +0 -23
  142. package/tests/fixtures/components/TestProduct.ts +0 -23
  143. package/tests/fixtures/components/TestUser.ts +0 -20
  144. package/tests/fixtures/components/index.ts +0 -6
  145. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  146. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  147. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  148. package/tests/helpers/MockRedisClient.ts +0 -113
  149. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  150. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  151. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  152. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  153. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  154. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  155. package/tests/integration/query/Query.abort.test.ts +0 -66
  156. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  157. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  158. package/tests/integration/query/Query.exec.test.ts +0 -576
  159. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  160. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  161. package/tests/integration/remote/dlq.test.ts +0 -175
  162. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  163. package/tests/integration/remote/outbox.test.ts +0 -130
  164. package/tests/integration/remote/rpc.test.ts +0 -177
  165. package/tests/pglite-setup.ts +0 -62
  166. package/tests/setup.ts +0 -164
  167. package/tests/stress/BenchmarkRunner.ts +0 -203
  168. package/tests/stress/DataSeeder.ts +0 -190
  169. package/tests/stress/StressTestReporter.ts +0 -229
  170. package/tests/stress/cursor-perf-test.ts +0 -171
  171. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  172. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  173. package/tests/stress/index.ts +0 -7
  174. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  175. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  176. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  177. package/tests/unit/BatchLoader.test.ts +0 -196
  178. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  179. package/tests/unit/cache/CacheManager.test.ts +0 -498
  180. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  181. package/tests/unit/cache/RedisCache.test.ts +0 -411
  182. package/tests/unit/database/cancellable.test.ts +0 -81
  183. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  184. package/tests/unit/entity/Entity.components.test.ts +0 -317
  185. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  186. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  187. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  188. package/tests/unit/entity/Entity.test.ts +0 -345
  189. package/tests/unit/gql/depthLimit.test.ts +0 -203
  190. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  191. package/tests/unit/health/Health.test.ts +0 -129
  192. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  193. package/tests/unit/middleware/Middleware.test.ts +0 -98
  194. package/tests/unit/middleware/RequestId.test.ts +0 -54
  195. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  196. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  197. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  198. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  199. package/tests/unit/query/Query.test.ts +0 -310
  200. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  201. package/tests/unit/remote/RemoteError.test.ts +0 -55
  202. package/tests/unit/remote/decorators.test.ts +0 -195
  203. package/tests/unit/remote/metrics.test.ts +0 -115
  204. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  205. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  206. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  207. package/tests/unit/schema/schema-integration.test.ts +0 -426
  208. package/tests/unit/schema/schema.test.ts +0 -580
  209. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  210. package/tests/unit/upload/RestUpload.test.ts +0 -267
  211. package/tests/unit/validateEnv.test.ts +0 -82
  212. package/tests/utils/entity-tracker.ts +0 -57
  213. package/tests/utils/index.ts +0 -13
  214. package/tests/utils/test-context.ts +0 -149
package/query/Query.ts CHANGED
@@ -14,6 +14,12 @@ import { shouldUseDirectPartition } from "../core/Config";
14
14
  import type { SQL } from "bun";
15
15
  import type { ComponentConstructor, TypedEntity, ComponentRecord } from "../types/query.types";
16
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;
17
23
 
18
24
  export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "ILIKE" | "IN" | "NOT IN" | string;
19
25
 
@@ -154,6 +160,9 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
154
160
  if (componentCtorOrComponentsOrOrQuery instanceof OrQuery) {
155
161
  // Handle OR query
156
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;
157
166
  return this;
158
167
  }
159
168
 
@@ -300,6 +309,9 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
300
309
  /**
301
310
  * Bypass cache for this query.
302
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.
303
315
  */
304
316
  public noCache(): this;
305
317
  public noCache(options: QueryCacheOptions): this;
@@ -325,6 +337,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
325
337
  logger.error(`Query count execution timeout`);
326
338
  reject(new Error(`Query count execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
327
339
  }, QUERY_TIMEOUT_MS);
340
+ (timeout as unknown as { unref?: () => void }).unref?.();
328
341
  this.doCount()
329
342
  .then(result => {
330
343
  clearTimeout(timeout);
@@ -380,12 +393,32 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
380
393
  const dbConn = this.getDb();
381
394
 
382
395
  // Use PostgreSQL's statistics for fast count estimate
383
- // This queries pg_class which is O(1) instead of scanning the table
384
- const sql = tableName && tableName !== 'components'
385
- ? `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`
386
- : `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
+ }
387
420
 
388
- const result = await timedUnsafe<any[]>(dbConn, sql, [tableName || 'entity_components'], this.execSignal, this.execPerRequest);
421
+ const result = await timedUnsafe<any[]>(dbConn, sql, params, this.execSignal, this.execPerRequest);
389
422
 
390
423
  if (!result || result.length === 0 || result[0].estimate === null) {
391
424
  // Fallback to exact count if statistics not available
@@ -396,6 +429,32 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
396
429
  }
397
430
 
398
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> {
399
458
  // Build the DAG
400
459
  const dag = new QueryDAG();
401
460
 
@@ -437,18 +496,11 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
437
496
  // Get the database connection (transaction or default)
438
497
  const dbConn = this.getDb();
439
498
 
440
- let countResult: any[];
441
-
442
- if (this.skipPreparedCache) {
443
- // Bypass cache - execute directly
444
- countResult = await timedUnsafe<any[]>(dbConn, countSql, result.params, this.execSignal, this.execPerRequest);
445
- } else {
446
- // Check prepared statement cache
447
- // Add 'count:' prefix to differentiate count queries from exec queries
448
- const cacheKey = 'count:' + this.context.generateCacheKey();
449
- const { statement, isHit } = await preparedStatementCache.getOrCreate(countSql, cacheKey, dbConn);
450
- countResult = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
451
- }
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);
452
504
 
453
505
  // Debug logging
454
506
  if (this.debug) {
@@ -496,6 +548,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
496
548
  logger.error(`Query sum execution timeout`);
497
549
  reject(new Error(`Query sum execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
498
550
  }, QUERY_TIMEOUT_MS);
551
+ (timeout as unknown as { unref?: () => void }).unref?.();
499
552
  this.doAggregate('SUM', componentCtor, field as string)
500
553
  .then(result => {
501
554
  clearTimeout(timeout);
@@ -524,6 +577,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
524
577
  logger.error(`Query average execution timeout`);
525
578
  reject(new Error(`Query average execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
526
579
  }, QUERY_TIMEOUT_MS);
580
+ (timeout as unknown as { unref?: () => void }).unref?.();
527
581
  this.doAggregate('AVG', componentCtor, field as string)
528
582
  .then(result => {
529
583
  clearTimeout(timeout);
@@ -631,15 +685,9 @@ AND c.deleted_at IS NULL`;
631
685
  // Get the database connection
632
686
  const dbConn = this.getDb();
633
687
 
634
- let aggregateResult: any[];
635
-
636
- if (this.skipPreparedCache) {
637
- aggregateResult = await timedUnsafe<any[]>(dbConn, aggregateSql, result.params, this.execSignal, this.execPerRequest);
638
- } else {
639
- const cacheKey = `${aggregateType.toLowerCase()}:${typeId}:${field}:` + this.context.generateCacheKey();
640
- const { statement } = await preparedStatementCache.getOrCreate(aggregateSql, cacheKey, dbConn);
641
- aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
642
- }
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);
643
691
 
644
692
  // Debug logging
645
693
  if (this.debug) {
@@ -684,10 +732,14 @@ AND c.deleted_at IS NULL`;
684
732
  // warn once at execution so developers notice runaway queries
685
733
  // (H-QUERY-1).
686
734
  if (this.context.limit === null || this.context.limit === undefined) {
687
- const envLimit = parseInt(process.env.BUNSANE_DEFAULT_QUERY_LIMIT ?? '10000', 10);
688
- if (envLimit > 0) {
689
- this.context.limit = envLimit;
690
- 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
+ }
691
743
  }
692
744
  }
693
745
 
@@ -697,6 +749,9 @@ AND c.deleted_at IS NULL`;
697
749
  logger.error(`Query execution timeout`);
698
750
  reject(new Error(`Query execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
699
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?.();
700
755
 
701
756
  this.doExec()
702
757
  .then(result => {
@@ -833,18 +888,13 @@ AND c.deleted_at IS NULL`;
833
888
  }
834
889
  }
835
890
 
836
- let entities: any[];
837
-
838
- if (this.orQuery || this.skipPreparedCache) {
839
- // For OR queries or explicit cache bypass, execute directly
840
- // This avoids potential parameter type inference issues with Bun's SQL
841
- entities = await timedUnsafe<any[]>(dbConn, result.sql, result.params, this.execSignal, this.execPerRequest);
842
- } else {
843
- // Check prepared statement cache for regular queries
844
- const cacheKey = this.context.generateCacheKey();
845
- const { statement, isHit } = await preparedStatementCache.getOrCreate(result.sql, cacheKey, dbConn);
846
- entities = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
847
- }
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);
848
898
 
849
899
  // Convert to Entity objects
850
900
  const entityIds: string[] = entities.map((row: any) => row.id);
@@ -898,13 +948,15 @@ AND c.deleted_at IS NULL`;
898
948
  // Get the database connection (transaction or default)
899
949
  const dbConn = this.getDb();
900
950
 
951
+ // created_at/updated_at included so results can warm the component
952
+ // cache below with full ComponentData entries.
901
953
  let components: any[];
902
954
  if (shouldUseDirectPartition() && componentTypeIds.length === 1) {
903
955
  // Single component type - use direct partition if available
904
956
  const partitionTableName = ComponentRegistry.getPartitionTableName(componentTypeIds[0]!);
905
957
  if (partitionTableName) {
906
958
  components = await timedUnsafe<any[]>(dbConn, `
907
- SELECT id, entity_id, type_id, data
959
+ SELECT id, entity_id, type_id, data, created_at, updated_at
908
960
  FROM ${partitionTableName}
909
961
  WHERE entity_id IN ${entityIdList.sql}
910
962
  AND type_id IN ${typeIdList.sql}
@@ -913,7 +965,7 @@ AND c.deleted_at IS NULL`;
913
965
  } else {
914
966
  // Fallback to parent table
915
967
  components = await timedUnsafe<any[]>(dbConn, `
916
- SELECT id, entity_id, type_id, data
968
+ SELECT id, entity_id, type_id, data, created_at, updated_at
917
969
  FROM components
918
970
  WHERE entity_id IN ${entityIdList.sql}
919
971
  AND type_id IN ${typeIdList.sql}
@@ -923,7 +975,7 @@ AND c.deleted_at IS NULL`;
923
975
  } else {
924
976
  // Multiple types or direct partition disabled - use parent table
925
977
  components = await timedUnsafe<any[]>(dbConn, `
926
- SELECT id, entity_id, type_id, data
978
+ SELECT id, entity_id, type_id, data, created_at, updated_at
927
979
  FROM components
928
980
  WHERE entity_id IN ${entityIdList.sql}
929
981
  AND type_id IN ${typeIdList.sql}
@@ -971,6 +1023,62 @@ AND c.deleted_at IS NULL`;
971
1023
  // Add component to entity (using protected method)
972
1024
  (entity as any).addComponent(component);
973
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
+ })());
974
1082
  }
975
1083
 
976
1084
  /**
@@ -1033,7 +1141,10 @@ AND c.deleted_at IS NULL`;
1033
1141
  }
1034
1142
 
1035
1143
  /**
1036
- * 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.
1037
1148
  */
1038
1149
  public static getCacheStats() {
1039
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}`);