bunsane 0.3.2 → 0.5.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 (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -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 +8 -7
  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 +25 -10
  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 +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. package/tests/utils/test-context.ts +0 -149
package/query/Query.ts CHANGED
@@ -14,6 +14,46 @@ 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;
23
+
24
+ // Gated once — dev keeps param diagnostics, production skips the loop entirely.
25
+ const DEBUG_PARAMS = process.env.NODE_ENV !== 'production';
26
+
27
+ // Shared across all TypedEntity instances — avoids one closure allocation per row.
28
+ // Must be called as a method (entity.getTyped(Ctor)) so `this` resolves correctly.
29
+ async function sharedGetTyped(
30
+ this: any,
31
+ ctor: any
32
+ ): Promise<any> {
33
+ const data = await this.get(ctor);
34
+ if (!data) {
35
+ throw new Error(`Component ${ctor.name} not found on entity ${this.id}, but it was expected from query`);
36
+ }
37
+ return data;
38
+ }
39
+
40
+ // Hoisted descriptor for _queriedComponents — non-enumerable by design (hidden from
41
+ // Object.keys / spreads). Descriptor is reused; only `value` is patched per row.
42
+ const queriedComponentsDescriptor: PropertyDescriptor = {
43
+ value: undefined as any,
44
+ writable: false,
45
+ enumerable: false,
46
+ configurable: false,
47
+ };
48
+
49
+ // getTyped stays non-enumerable like the original defineProperty version; the value
50
+ // never varies, so the descriptor is fully static.
51
+ const getTypedDescriptor: PropertyDescriptor = {
52
+ value: sharedGetTyped,
53
+ writable: false,
54
+ enumerable: false,
55
+ configurable: false,
56
+ };
17
57
 
18
58
  export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "ILIKE" | "IN" | "NOT IN" | string;
19
59
 
@@ -154,6 +194,9 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
154
194
  if (componentCtorOrComponentsOrOrQuery instanceof OrQuery) {
155
195
  // Handle OR query
156
196
  this.orQuery = componentCtorOrComponentsOrOrQuery;
197
+ // Suppress base-level scan optimizations that bake ORDER/LIMIT
198
+ // into the SQL OrNode later embeds as its base set.
199
+ this.context.hasOrQuery = true;
157
200
  return this;
158
201
  }
159
202
 
@@ -300,6 +343,9 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
300
343
  /**
301
344
  * Bypass cache for this query.
302
345
  * @param options Cache options to bypass. If not provided, bypasses prepared statement cache.
346
+ * Note: the prepared-statement option is now a no-op (queries always
347
+ * execute directly; Bun SQL handles statement preparation). The
348
+ * `component` option still controls the component cache.
303
349
  */
304
350
  public noCache(): this;
305
351
  public noCache(options: QueryCacheOptions): this;
@@ -325,6 +371,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
325
371
  logger.error(`Query count execution timeout`);
326
372
  reject(new Error(`Query count execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
327
373
  }, QUERY_TIMEOUT_MS);
374
+ (timeout as unknown as { unref?: () => void }).unref?.();
328
375
  this.doCount()
329
376
  .then(result => {
330
377
  clearTimeout(timeout);
@@ -380,12 +427,32 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
380
427
  const dbConn = this.getDb();
381
428
 
382
429
  // 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'`;
430
+ // This queries pg_class which is O(1) instead of scanning the table.
431
+ // When the component resolves to a specific partition table, read its
432
+ // reltuples directly. Otherwise fall back to the membership source:
433
+ // legacy reads `entity_components` reltuples; the components source
434
+ // sums the LIST-partition child stats (the partitioned parent's
435
+ // reltuples is unreliable).
436
+ let sql: string;
437
+ let params: any[];
438
+ if (tableName && tableName !== 'components') {
439
+ sql = `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`;
440
+ params = [tableName];
441
+ } else if (getMembershipSource().isLegacy) {
442
+ sql = `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'entity_components'`;
443
+ params = [];
444
+ } else {
445
+ // No COALESCE: an empty partition set must yield NULL so the
446
+ // exact-count fallback below triggers, matching the legacy
447
+ // zero-rows behavior.
448
+ sql = `SELECT SUM(c.reltuples)::bigint AS estimate
449
+ FROM pg_class c
450
+ JOIN pg_inherits i ON c.oid = i.inhrelid
451
+ WHERE i.inhparent = 'components'::regclass`;
452
+ params = [];
453
+ }
387
454
 
388
- const result = await timedUnsafe<any[]>(dbConn, sql, [tableName || 'entity_components'], this.execSignal, this.execPerRequest);
455
+ const result = await timedUnsafe<any[]>(dbConn, sql, params, this.execSignal, this.execPerRequest);
389
456
 
390
457
  if (!result || result.length === 0 || result[0].estimate === null) {
391
458
  // Fallback to exact count if statistics not available
@@ -396,6 +463,32 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
396
463
  }
397
464
 
398
465
  private async doCount(): Promise<number> {
466
+ // Fresh params for re-execution. doExec/doAggregate already reset;
467
+ // missing here meant stale params (wrong bindings) on Query reuse.
468
+ this.context.reset();
469
+
470
+ // count() must return total matching cardinality. Pagination and
471
+ // sort must not leak into the counted subquery — a LIMIT inside the
472
+ // subquery caps the count (after a prior exec() the framework
473
+ // default LIMIT silently capped every count at
474
+ // BUNSANE_DEFAULT_QUERY_LIMIT), and ORDER BY is wasted work under
475
+ // COUNT(*). Save/restore so exec() after count() behaves unchanged.
476
+ const savedLimit = this.context.limit;
477
+ const savedOffset = this.context.offsetValue;
478
+ const savedSorts = this.context.sortOrders;
479
+ this.context.limit = null;
480
+ this.context.offsetValue = 0;
481
+ this.context.sortOrders = [];
482
+ try {
483
+ return await this.doCountInner();
484
+ } finally {
485
+ this.context.limit = savedLimit;
486
+ this.context.offsetValue = savedOffset;
487
+ this.context.sortOrders = savedSorts;
488
+ }
489
+ }
490
+
491
+ private async doCountInner(): Promise<number> {
399
492
  // Build the DAG
400
493
  const dag = new QueryDAG();
401
494
 
@@ -437,18 +530,11 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
437
530
  // Get the database connection (transaction or default)
438
531
  const dbConn = this.getDb();
439
532
 
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
- }
533
+ // Execute directly. Bun SQL auto-prepares parameterized statements
534
+ // per connection (prepare:true default) — the former framework-level
535
+ // "prepared statement cache" never called a prepare API and only
536
+ // added cache-key string building on the hot path.
537
+ const countResult: any[] = await timedUnsafe<any[]>(dbConn, countSql, result.params, this.execSignal, this.execPerRequest);
452
538
 
453
539
  // Debug logging
454
540
  if (this.debug) {
@@ -496,6 +582,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
496
582
  logger.error(`Query sum execution timeout`);
497
583
  reject(new Error(`Query sum execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
498
584
  }, QUERY_TIMEOUT_MS);
585
+ (timeout as unknown as { unref?: () => void }).unref?.();
499
586
  this.doAggregate('SUM', componentCtor, field as string)
500
587
  .then(result => {
501
588
  clearTimeout(timeout);
@@ -524,6 +611,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
524
611
  logger.error(`Query average execution timeout`);
525
612
  reject(new Error(`Query average execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
526
613
  }, QUERY_TIMEOUT_MS);
614
+ (timeout as unknown as { unref?: () => void }).unref?.();
527
615
  this.doAggregate('AVG', componentCtor, field as string)
528
616
  .then(result => {
529
617
  clearTimeout(timeout);
@@ -631,15 +719,9 @@ AND c.deleted_at IS NULL`;
631
719
  // Get the database connection
632
720
  const dbConn = this.getDb();
633
721
 
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
- }
722
+ // Direct execution — see doCountInner for why the framework-level
723
+ // prepared statement cache was removed from the hot path.
724
+ const aggregateResult: any[] = await timedUnsafe<any[]>(dbConn, aggregateSql, result.params, this.execSignal, this.execPerRequest);
643
725
 
644
726
  // Debug logging
645
727
  if (this.debug) {
@@ -684,10 +766,14 @@ AND c.deleted_at IS NULL`;
684
766
  // warn once at execution so developers notice runaway queries
685
767
  // (H-QUERY-1).
686
768
  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.');
769
+ if (DEFAULT_QUERY_LIMIT > 0) {
770
+ this.context.limit = DEFAULT_QUERY_LIMIT;
771
+ // Warn once per process — this fires on every unbounded query,
772
+ // so logging per-call floods logs and allocates on the hot path.
773
+ if (!warnedDefaultLimit) {
774
+ warnedDefaultLimit = true;
775
+ 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.');
776
+ }
691
777
  }
692
778
  }
693
779
 
@@ -697,6 +783,9 @@ AND c.deleted_at IS NULL`;
697
783
  logger.error(`Query execution timeout`);
698
784
  reject(new Error(`Query execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
699
785
  }, QUERY_TIMEOUT_MS); // 30 second timeout
786
+ // unref: at high QPS thousands of these are live concurrently;
787
+ // they must not hold the event loop open nor add ref'd-timer churn.
788
+ (timeout as unknown as { unref?: () => void }).unref?.();
700
789
 
701
790
  this.doExec()
702
791
  .then(result => {
@@ -731,34 +820,16 @@ AND c.deleted_at IS NULL`;
731
820
  // Create typed entity wrapper
732
821
  const typedEntity = entity as TypedEntity<TComponents>;
733
822
 
734
- // Define componentData property
735
- Object.defineProperty(typedEntity, 'componentData', {
736
- value: componentData as ComponentRecord<TComponents>,
737
- writable: false,
738
- enumerable: true
739
- });
823
+ // Plain assignment — enumerable: true matches prior behavior; no defineProperty overhead.
824
+ (typedEntity as any).componentData = componentData as ComponentRecord<TComponents>;
740
825
 
741
- // Define _queriedComponents property for runtime reflection
742
- Object.defineProperty(typedEntity, '_queriedComponents', {
743
- value: componentCtors as unknown as TComponents,
744
- writable: false,
745
- enumerable: false
746
- });
826
+ // _queriedComponents must stay non-enumerable (hidden from Object.keys / spreads).
827
+ queriedComponentsDescriptor.value = componentCtors as unknown as TComponents;
828
+ Object.defineProperty(typedEntity, '_queriedComponents', queriedComponentsDescriptor);
829
+ queriedComponentsDescriptor.value = undefined; // don't retain ref
747
830
 
748
- // Define getTyped method
749
- Object.defineProperty(typedEntity, 'getTyped', {
750
- value: async function<T extends TComponents[number]>(
751
- ctor: T
752
- ): Promise<T extends ComponentConstructor<infer C> ? C extends BaseComponent ? ComponentDataType<C> : never : never> {
753
- const data = await entity.get(ctor as any);
754
- if (!data) {
755
- throw new Error(`Component ${(ctor as any).name} not found on entity ${entity.id}, but it was expected from query`);
756
- }
757
- return data as any;
758
- },
759
- writable: false,
760
- enumerable: false
761
- });
831
+ // Shared function — one allocation per module, not per row.
832
+ Object.defineProperty(typedEntity, 'getTyped', getTypedDescriptor);
762
833
 
763
834
  return typedEntity;
764
835
  }
@@ -823,28 +894,25 @@ AND c.deleted_at IS NULL`;
823
894
  // originate from saved entities. PG emits a clear error at
824
895
  // execution time if a UUID cast meets an empty string.
825
896
 
826
- // Validate parameters before execution
827
- for (let i = 0; i < result.params.length; i++) {
828
- if (result.params[i] === undefined || result.params[i] === null) {
829
- console.error(`❌ Query parameter $${i + 1} is undefined/null`);
830
- console.error(`SQL: ${result.sql}`);
831
- console.error(`All params: ${JSON.stringify(result.params)}`);
832
- throw new Error(`Query parameter $${i + 1} is undefined/null. SQL: ${result.sql.substring(0, 100)}...`);
897
+ // Validate parameters before execution (dev only — skipped in production)
898
+ if (DEBUG_PARAMS) {
899
+ for (let i = 0; i < result.params.length; i++) {
900
+ if (result.params[i] === undefined || result.params[i] === null) {
901
+ console.error(`❌ Query parameter $${i + 1} is undefined/null`);
902
+ console.error(`SQL: ${result.sql}`);
903
+ console.error(`All params: ${JSON.stringify(result.params)}`);
904
+ throw new Error(`Query parameter $${i + 1} is undefined/null. SQL: ${result.sql.substring(0, 100)}...`);
905
+ }
833
906
  }
834
907
  }
835
908
 
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
- }
909
+ // Execute directly. Bun SQL auto-prepares parameterized statements
910
+ // per connection (prepare:true default), so server-side plan reuse
911
+ // already happens at the driver layer. The former framework-level
912
+ // "prepared statement cache" stored a placeholder object and
913
+ // re-executed db.unsafe anyway pure cache-key/bookkeeping overhead
914
+ // on every exec.
915
+ const entities: any[] = await timedUnsafe<any[]>(dbConn, result.sql, result.params, this.execSignal, this.execPerRequest);
848
916
 
849
917
  // Convert to Entity objects
850
918
  const entityIds: string[] = entities.map((row: any) => row.id);
@@ -898,13 +966,15 @@ AND c.deleted_at IS NULL`;
898
966
  // Get the database connection (transaction or default)
899
967
  const dbConn = this.getDb();
900
968
 
969
+ // created_at/updated_at included so results can warm the component
970
+ // cache below with full ComponentData entries.
901
971
  let components: any[];
902
972
  if (shouldUseDirectPartition() && componentTypeIds.length === 1) {
903
973
  // Single component type - use direct partition if available
904
974
  const partitionTableName = ComponentRegistry.getPartitionTableName(componentTypeIds[0]!);
905
975
  if (partitionTableName) {
906
976
  components = await timedUnsafe<any[]>(dbConn, `
907
- SELECT id, entity_id, type_id, data
977
+ SELECT id, entity_id, type_id, data, created_at, updated_at
908
978
  FROM ${partitionTableName}
909
979
  WHERE entity_id IN ${entityIdList.sql}
910
980
  AND type_id IN ${typeIdList.sql}
@@ -913,7 +983,7 @@ AND c.deleted_at IS NULL`;
913
983
  } else {
914
984
  // Fallback to parent table
915
985
  components = await timedUnsafe<any[]>(dbConn, `
916
- SELECT id, entity_id, type_id, data
986
+ SELECT id, entity_id, type_id, data, created_at, updated_at
917
987
  FROM components
918
988
  WHERE entity_id IN ${entityIdList.sql}
919
989
  AND type_id IN ${typeIdList.sql}
@@ -923,7 +993,7 @@ AND c.deleted_at IS NULL`;
923
993
  } else {
924
994
  // Multiple types or direct partition disabled - use parent table
925
995
  components = await timedUnsafe<any[]>(dbConn, `
926
- SELECT id, entity_id, type_id, data
996
+ SELECT id, entity_id, type_id, data, created_at, updated_at
927
997
  FROM components
928
998
  WHERE entity_id IN ${entityIdList.sql}
929
999
  AND type_id IN ${typeIdList.sql}
@@ -971,6 +1041,62 @@ AND c.deleted_at IS NULL`;
971
1041
  // Add component to entity (using protected method)
972
1042
  (entity as any).addComponent(component);
973
1043
  }
1044
+
1045
+ this.warmComponentCache(components, entityIds, componentTypeIds);
1046
+ }
1047
+
1048
+ /**
1049
+ * Fire-and-forget warm of the L1/L2 component cache from populate()
1050
+ * results, so subsequent `entity.get(X)` calls (same or later request)
1051
+ * hit cache instead of re-querying. Previously populate() bypassed the
1052
+ * cache entirely — only the DataLoader read path warmed it.
1053
+ *
1054
+ * Tracked via Entity.trackCacheOp so shutdown/tests can drain it.
1055
+ * Skipped for large result sets to avoid hammering the cache provider
1056
+ * with bulk-scan output.
1057
+ */
1058
+ private warmComponentCache(components: any[], entityIds: string[], componentTypeIds: string[]): void {
1059
+ const WARM_CACHE_MAX = 1000;
1060
+ if (this.skipComponentCache || this.trx) return;
1061
+ if (components.length === 0 || components.length > WARM_CACHE_MAX) return;
1062
+
1063
+ Entity.trackCacheOp((async () => {
1064
+ try {
1065
+ const { CacheManager } = await import('../core/cache/CacheManager');
1066
+ const cacheManager = CacheManager.getInstance();
1067
+ const config = cacheManager.getConfig();
1068
+ if (!config.enabled || !config.component?.enabled) return;
1069
+
1070
+ // Requested (entity × type) pairs let the cache tombstone
1071
+ // known-absent components. Only built when the pair count is
1072
+ // bounded — tombstoning a huge scan is not worth the writes.
1073
+ let requested: Array<{ entityId: string; typeId: string }> | undefined;
1074
+ if (entityIds.length * componentTypeIds.length <= WARM_CACHE_MAX) {
1075
+ requested = [];
1076
+ for (const entityId of entityIds) {
1077
+ for (const typeId of componentTypeIds) {
1078
+ requested.push({ entityId, typeId });
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ await cacheManager.setComponentsWriteThrough(
1084
+ components.map((row: any) => ({
1085
+ id: row.id,
1086
+ entityId: row.entity_id,
1087
+ typeId: row.type_id,
1088
+ data: row.data,
1089
+ createdAt: row.created_at,
1090
+ updatedAt: row.updated_at,
1091
+ deletedAt: null,
1092
+ })),
1093
+ requested,
1094
+ config.component.ttl,
1095
+ );
1096
+ } catch (error) {
1097
+ logger.warn({ scope: 'cache', component: 'Query', msg: 'populate() component cache warm failed', error });
1098
+ }
1099
+ })());
974
1100
  }
975
1101
 
976
1102
  /**
@@ -1033,7 +1159,10 @@ AND c.deleted_at IS NULL`;
1033
1159
  }
1034
1160
 
1035
1161
  /**
1036
- * Get prepared statement cache statistics
1162
+ * Get prepared statement cache statistics.
1163
+ * @deprecated The framework-level prepared statement cache is no longer
1164
+ * used on the query hot path (Bun SQL auto-prepares at the driver
1165
+ * layer). Stats remain for API compatibility and report an idle cache.
1037
1166
  */
1038
1167
  public static getCacheStats() {
1039
1168
  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}`);