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.
- package/CHANGELOG.md +445 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -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 +4 -4
- 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 +16 -8
- 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 +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- 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/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/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 +15 -3
- package/database/instrumentedDb.ts +141 -141
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +157 -46
- 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,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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
this
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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();
|
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}`);
|