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.
- package/CHANGELOG.md +471 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +93 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +8 -7
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +25 -10
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +383 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +375 -0
- package/core/health.ts +93 -4
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/StreamConsumer.ts +535 -535
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/core/validateEnv.ts +10 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +29 -3
- package/database/instrumentedDb.ts +141 -141
- package/database/sqlHelpers.ts +3 -1
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/schema/index.ts +15 -4
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +245 -14
- package/query/OrNode.ts +8 -19
- package/query/Query.ts +208 -79
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- 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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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,
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
this
|
|
690
|
-
|
|
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
|
-
//
|
|
735
|
-
|
|
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
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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();
|
package/query/QueryContext.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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}`);
|