bunsane 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +445 -318
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1043
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +64 -6
- package/core/RequestLoaders.ts +187 -36
- package/core/SchedulerManager.ts +28 -600
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +85 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +15 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +310 -0
- package/core/app/restRegistry.ts +80 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +666 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +161 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +173 -267
- 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/AccessLog.ts +8 -1
- 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 +20 -5
- package/database/cancellable.ts +35 -0
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -0
- 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 +203 -59
- 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/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/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/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 -367
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- 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
|
@@ -8,11 +8,18 @@ import { QueryContext, QueryDAG, SourceNode, ComponentInclusionNode } from "./in
|
|
|
8
8
|
import { OrQuery } from "./OrQuery";
|
|
9
9
|
import { OrNode } from "./OrNode";
|
|
10
10
|
import { preparedStatementCache } from "../database/PreparedStatementCache";
|
|
11
|
+
import { timedUnsafe, type PerRequestCounters } from "../database/instrumentedDb";
|
|
11
12
|
import { getMetadataStorage } from "../core/metadata";
|
|
12
13
|
import { shouldUseDirectPartition } from "../core/Config";
|
|
13
14
|
import type { SQL } from "bun";
|
|
14
15
|
import type { ComponentConstructor, TypedEntity, ComponentRecord } from "../types/query.types";
|
|
15
16
|
import { assertComponentTableName, assertFieldPath } from "./SqlIdentifier";
|
|
17
|
+
import { getMembershipSource } from "./membershipSource";
|
|
18
|
+
|
|
19
|
+
// Parsed once at module load instead of on every exec() (process.env read +
|
|
20
|
+
// parseInt was on the query hot path). 0 disables the default limit.
|
|
21
|
+
const DEFAULT_QUERY_LIMIT = parseInt(process.env.BUNSANE_DEFAULT_QUERY_LIMIT ?? '10000', 10);
|
|
22
|
+
let warnedDefaultLimit = false;
|
|
16
23
|
|
|
17
24
|
export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "ILIKE" | "IN" | "NOT IN" | string;
|
|
18
25
|
|
|
@@ -62,6 +69,21 @@ export interface QueryCacheOptions {
|
|
|
62
69
|
component?: boolean;
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Options accepted by Query terminal methods (`exec`, `count`, `sum`, etc.).
|
|
74
|
+
* - `signal` cancels in-flight DB queries via Bun's `Query.cancel()` when
|
|
75
|
+
* fired. The request-scoped signal from `req.signal` is automatically
|
|
76
|
+
* threaded into resolver-level Query instances by the framework's
|
|
77
|
+
* GraphQL request context plugin; manual callers pass it explicitly.
|
|
78
|
+
* - `perRequest` is an opaque counter object incremented by the
|
|
79
|
+
* instrumented DB layer so per-request stats (dbQueryCount,
|
|
80
|
+
* dataLoaderCalls) are reported on access/timeout logs.
|
|
81
|
+
*/
|
|
82
|
+
export interface QueryExecOptions {
|
|
83
|
+
signal?: AbortSignal;
|
|
84
|
+
perRequest?: PerRequestCounters;
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
/**
|
|
66
88
|
* New Query class that uses DAG internally for better modularity and extensibility.
|
|
67
89
|
*
|
|
@@ -85,6 +107,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
85
107
|
private trx: SQL | undefined;
|
|
86
108
|
private skipPreparedCache: boolean = false;
|
|
87
109
|
private skipComponentCache: boolean = false;
|
|
110
|
+
private execSignal?: AbortSignal;
|
|
111
|
+
private execPerRequest?: PerRequestCounters;
|
|
88
112
|
|
|
89
113
|
/** Component constructors added to this query for type-safe access */
|
|
90
114
|
private _componentCtors: ComponentConstructor[] = [];
|
|
@@ -110,12 +134,12 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
110
134
|
return this;
|
|
111
135
|
}
|
|
112
136
|
|
|
113
|
-
public async findOneById(id: string): Promise<TypedEntity<TComponents> | null> {
|
|
137
|
+
public async findOneById(id: string, opts?: QueryExecOptions): Promise<TypedEntity<TComponents> | null> {
|
|
114
138
|
// Validate ID to prevent PostgreSQL UUID parsing errors
|
|
115
139
|
if (!id || typeof id !== 'string' || id.trim() === '') {
|
|
116
140
|
return null;
|
|
117
141
|
}
|
|
118
|
-
const entities = await this.findById(id).exec();
|
|
142
|
+
const entities = await this.findById(id).exec(opts);
|
|
119
143
|
return entities.length > 0 ? entities[0]! : null;
|
|
120
144
|
}
|
|
121
145
|
|
|
@@ -136,6 +160,9 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
136
160
|
if (componentCtorOrComponentsOrOrQuery instanceof OrQuery) {
|
|
137
161
|
// Handle OR query
|
|
138
162
|
this.orQuery = componentCtorOrComponentsOrOrQuery;
|
|
163
|
+
// Suppress base-level scan optimizations that bake ORDER/LIMIT
|
|
164
|
+
// into the SQL OrNode later embeds as its base set.
|
|
165
|
+
this.context.hasOrQuery = true;
|
|
139
166
|
return this;
|
|
140
167
|
}
|
|
141
168
|
|
|
@@ -282,6 +309,9 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
282
309
|
/**
|
|
283
310
|
* Bypass cache for this query.
|
|
284
311
|
* @param options Cache options to bypass. If not provided, bypasses prepared statement cache.
|
|
312
|
+
* Note: the prepared-statement option is now a no-op (queries always
|
|
313
|
+
* execute directly; Bun SQL handles statement preparation). The
|
|
314
|
+
* `component` option still controls the component cache.
|
|
285
315
|
*/
|
|
286
316
|
public noCache(): this;
|
|
287
317
|
public noCache(options: QueryCacheOptions): this;
|
|
@@ -300,12 +330,14 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
300
330
|
return this;
|
|
301
331
|
}
|
|
302
332
|
|
|
303
|
-
public count(): Promise<number> {
|
|
333
|
+
public count(opts?: QueryExecOptions): Promise<number> {
|
|
334
|
+
this.applyExecOptions(opts);
|
|
304
335
|
return new Promise<number>((resolve, reject) => {
|
|
305
336
|
const timeout = setTimeout(() => {
|
|
306
337
|
logger.error(`Query count execution timeout`);
|
|
307
338
|
reject(new Error(`Query count execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
|
|
308
339
|
}, QUERY_TIMEOUT_MS);
|
|
340
|
+
(timeout as unknown as { unref?: () => void }).unref?.();
|
|
309
341
|
this.doCount()
|
|
310
342
|
.then(result => {
|
|
311
343
|
clearTimeout(timeout);
|
|
@@ -318,6 +350,17 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
318
350
|
});
|
|
319
351
|
}
|
|
320
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Apply terminal-method options to instance fields so internal helpers
|
|
355
|
+
* (doCount, doExec, populateComponents, doAggregate, …) can read them
|
|
356
|
+
* without threading parameters through every private method.
|
|
357
|
+
*/
|
|
358
|
+
private applyExecOptions(opts?: QueryExecOptions): void {
|
|
359
|
+
if (!opts) return;
|
|
360
|
+
if (opts.signal !== undefined) this.execSignal = opts.signal;
|
|
361
|
+
if (opts.perRequest !== undefined) this.execPerRequest = opts.perRequest;
|
|
362
|
+
}
|
|
363
|
+
|
|
321
364
|
/**
|
|
322
365
|
* Get an estimated count using PostgreSQL statistics.
|
|
323
366
|
* Much faster than exact count() for large tables - O(1) instead of O(n).
|
|
@@ -333,7 +376,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
333
376
|
* const approxCount = await new Query().with(User).estimatedCount(User);
|
|
334
377
|
* console.log(`Approximately ${approxCount} users`);
|
|
335
378
|
*/
|
|
336
|
-
public async estimatedCount(component: new (...args: any[]) => BaseComponent): Promise<number> {
|
|
379
|
+
public async estimatedCount(component: new (...args: any[]) => BaseComponent, opts?: QueryExecOptions): Promise<number> {
|
|
380
|
+
this.applyExecOptions(opts);
|
|
337
381
|
const typeId = ComponentRegistry.getComponentId(component.name);
|
|
338
382
|
if (!typeId) {
|
|
339
383
|
throw new Error(`Component ${component.name} not registered`);
|
|
@@ -349,12 +393,32 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
349
393
|
const dbConn = this.getDb();
|
|
350
394
|
|
|
351
395
|
// Use PostgreSQL's statistics for fast count estimate
|
|
352
|
-
// This queries pg_class which is O(1) instead of scanning the table
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
396
|
+
// This queries pg_class which is O(1) instead of scanning the table.
|
|
397
|
+
// When the component resolves to a specific partition table, read its
|
|
398
|
+
// reltuples directly. Otherwise fall back to the membership source:
|
|
399
|
+
// legacy reads `entity_components` reltuples; the components source
|
|
400
|
+
// sums the LIST-partition child stats (the partitioned parent's
|
|
401
|
+
// reltuples is unreliable).
|
|
402
|
+
let sql: string;
|
|
403
|
+
let params: any[];
|
|
404
|
+
if (tableName && tableName !== 'components') {
|
|
405
|
+
sql = `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`;
|
|
406
|
+
params = [tableName];
|
|
407
|
+
} else if (getMembershipSource().isLegacy) {
|
|
408
|
+
sql = `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'entity_components'`;
|
|
409
|
+
params = [];
|
|
410
|
+
} else {
|
|
411
|
+
// No COALESCE: an empty partition set must yield NULL so the
|
|
412
|
+
// exact-count fallback below triggers, matching the legacy
|
|
413
|
+
// zero-rows behavior.
|
|
414
|
+
sql = `SELECT SUM(c.reltuples)::bigint AS estimate
|
|
415
|
+
FROM pg_class c
|
|
416
|
+
JOIN pg_inherits i ON c.oid = i.inhrelid
|
|
417
|
+
WHERE i.inhparent = 'components'::regclass`;
|
|
418
|
+
params = [];
|
|
419
|
+
}
|
|
356
420
|
|
|
357
|
-
const result = await dbConn
|
|
421
|
+
const result = await timedUnsafe<any[]>(dbConn, sql, params, this.execSignal, this.execPerRequest);
|
|
358
422
|
|
|
359
423
|
if (!result || result.length === 0 || result[0].estimate === null) {
|
|
360
424
|
// Fallback to exact count if statistics not available
|
|
@@ -365,6 +429,32 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
365
429
|
}
|
|
366
430
|
|
|
367
431
|
private async doCount(): Promise<number> {
|
|
432
|
+
// Fresh params for re-execution. doExec/doAggregate already reset;
|
|
433
|
+
// missing here meant stale params (wrong bindings) on Query reuse.
|
|
434
|
+
this.context.reset();
|
|
435
|
+
|
|
436
|
+
// count() must return total matching cardinality. Pagination and
|
|
437
|
+
// sort must not leak into the counted subquery — a LIMIT inside the
|
|
438
|
+
// subquery caps the count (after a prior exec() the framework
|
|
439
|
+
// default LIMIT silently capped every count at
|
|
440
|
+
// BUNSANE_DEFAULT_QUERY_LIMIT), and ORDER BY is wasted work under
|
|
441
|
+
// COUNT(*). Save/restore so exec() after count() behaves unchanged.
|
|
442
|
+
const savedLimit = this.context.limit;
|
|
443
|
+
const savedOffset = this.context.offsetValue;
|
|
444
|
+
const savedSorts = this.context.sortOrders;
|
|
445
|
+
this.context.limit = null;
|
|
446
|
+
this.context.offsetValue = 0;
|
|
447
|
+
this.context.sortOrders = [];
|
|
448
|
+
try {
|
|
449
|
+
return await this.doCountInner();
|
|
450
|
+
} finally {
|
|
451
|
+
this.context.limit = savedLimit;
|
|
452
|
+
this.context.offsetValue = savedOffset;
|
|
453
|
+
this.context.sortOrders = savedSorts;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async doCountInner(): Promise<number> {
|
|
368
458
|
// Build the DAG
|
|
369
459
|
const dag = new QueryDAG();
|
|
370
460
|
|
|
@@ -406,18 +496,11 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
406
496
|
// Get the database connection (transaction or default)
|
|
407
497
|
const dbConn = this.getDb();
|
|
408
498
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
} else {
|
|
415
|
-
// Check prepared statement cache
|
|
416
|
-
// Add 'count:' prefix to differentiate count queries from exec queries
|
|
417
|
-
const cacheKey = 'count:' + this.context.generateCacheKey();
|
|
418
|
-
const { statement, isHit } = await preparedStatementCache.getOrCreate(countSql, cacheKey, dbConn);
|
|
419
|
-
countResult = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
420
|
-
}
|
|
499
|
+
// Execute directly. Bun SQL auto-prepares parameterized statements
|
|
500
|
+
// per connection (prepare:true default) — the former framework-level
|
|
501
|
+
// "prepared statement cache" never called a prepare API and only
|
|
502
|
+
// added cache-key string building on the hot path.
|
|
503
|
+
const countResult: any[] = await timedUnsafe<any[]>(dbConn, countSql, result.params, this.execSignal, this.execPerRequest);
|
|
421
504
|
|
|
422
505
|
// Debug logging
|
|
423
506
|
if (this.debug) {
|
|
@@ -465,6 +548,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
465
548
|
logger.error(`Query sum execution timeout`);
|
|
466
549
|
reject(new Error(`Query sum execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
|
|
467
550
|
}, QUERY_TIMEOUT_MS);
|
|
551
|
+
(timeout as unknown as { unref?: () => void }).unref?.();
|
|
468
552
|
this.doAggregate('SUM', componentCtor, field as string)
|
|
469
553
|
.then(result => {
|
|
470
554
|
clearTimeout(timeout);
|
|
@@ -493,6 +577,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
493
577
|
logger.error(`Query average execution timeout`);
|
|
494
578
|
reject(new Error(`Query average execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
|
|
495
579
|
}, QUERY_TIMEOUT_MS);
|
|
580
|
+
(timeout as unknown as { unref?: () => void }).unref?.();
|
|
496
581
|
this.doAggregate('AVG', componentCtor, field as string)
|
|
497
582
|
.then(result => {
|
|
498
583
|
clearTimeout(timeout);
|
|
@@ -600,15 +685,9 @@ AND c.deleted_at IS NULL`;
|
|
|
600
685
|
// Get the database connection
|
|
601
686
|
const dbConn = this.getDb();
|
|
602
687
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
aggregateResult = await dbConn.unsafe(aggregateSql, result.params);
|
|
607
|
-
} else {
|
|
608
|
-
const cacheKey = `${aggregateType.toLowerCase()}:${typeId}:${field}:` + this.context.generateCacheKey();
|
|
609
|
-
const { statement } = await preparedStatementCache.getOrCreate(aggregateSql, cacheKey, dbConn);
|
|
610
|
-
aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
611
|
-
}
|
|
688
|
+
// Direct execution — see doCountInner for why the framework-level
|
|
689
|
+
// prepared statement cache was removed from the hot path.
|
|
690
|
+
const aggregateResult: any[] = await timedUnsafe<any[]>(dbConn, aggregateSql, result.params, this.execSignal, this.execPerRequest);
|
|
612
691
|
|
|
613
692
|
// Debug logging
|
|
614
693
|
if (this.debug) {
|
|
@@ -645,17 +724,22 @@ AND c.deleted_at IS NULL`;
|
|
|
645
724
|
* @returns Promise resolving to array of TypedEntity with accumulated component types
|
|
646
725
|
*/
|
|
647
726
|
@timed("Query.exec")
|
|
648
|
-
public async exec(): Promise<TypedEntity<TComponents>[]> {
|
|
727
|
+
public async exec(opts?: QueryExecOptions): Promise<TypedEntity<TComponents>[]> {
|
|
728
|
+
this.applyExecOptions(opts);
|
|
649
729
|
// Apply default LIMIT so unbounded queries cannot load entire tables
|
|
650
730
|
// into memory. Configurable via BUNSANE_DEFAULT_QUERY_LIMIT, 0 to
|
|
651
731
|
// disable. When the default is applied without an explicit .take(),
|
|
652
732
|
// warn once at execution so developers notice runaway queries
|
|
653
733
|
// (H-QUERY-1).
|
|
654
734
|
if (this.context.limit === null || this.context.limit === undefined) {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
this
|
|
658
|
-
|
|
735
|
+
if (DEFAULT_QUERY_LIMIT > 0) {
|
|
736
|
+
this.context.limit = DEFAULT_QUERY_LIMIT;
|
|
737
|
+
// Warn once per process — this fires on every unbounded query,
|
|
738
|
+
// so logging per-call floods logs and allocates on the hot path.
|
|
739
|
+
if (!warnedDefaultLimit) {
|
|
740
|
+
warnedDefaultLimit = true;
|
|
741
|
+
logger.warn({ scope: 'Query.exec', defaultLimit: DEFAULT_QUERY_LIMIT }, 'Query executed without explicit .take() — applying framework default LIMIT. Call .take(N) to suppress this warning.');
|
|
742
|
+
}
|
|
659
743
|
}
|
|
660
744
|
}
|
|
661
745
|
|
|
@@ -665,6 +749,9 @@ AND c.deleted_at IS NULL`;
|
|
|
665
749
|
logger.error(`Query execution timeout`);
|
|
666
750
|
reject(new Error(`Query execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
|
|
667
751
|
}, QUERY_TIMEOUT_MS); // 30 second timeout
|
|
752
|
+
// unref: at high QPS thousands of these are live concurrently;
|
|
753
|
+
// they must not hold the event loop open nor add ref'd-timer churn.
|
|
754
|
+
(timeout as unknown as { unref?: () => void }).unref?.();
|
|
668
755
|
|
|
669
756
|
this.doExec()
|
|
670
757
|
.then(result => {
|
|
@@ -801,18 +888,13 @@ AND c.deleted_at IS NULL`;
|
|
|
801
888
|
}
|
|
802
889
|
}
|
|
803
890
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
// Check prepared statement cache for regular queries
|
|
812
|
-
const cacheKey = this.context.generateCacheKey();
|
|
813
|
-
const { statement, isHit } = await preparedStatementCache.getOrCreate(result.sql, cacheKey, dbConn);
|
|
814
|
-
entities = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
815
|
-
}
|
|
891
|
+
// Execute directly. Bun SQL auto-prepares parameterized statements
|
|
892
|
+
// per connection (prepare:true default), so server-side plan reuse
|
|
893
|
+
// already happens at the driver layer. The former framework-level
|
|
894
|
+
// "prepared statement cache" stored a placeholder object and
|
|
895
|
+
// re-executed db.unsafe anyway — pure cache-key/bookkeeping overhead
|
|
896
|
+
// on every exec.
|
|
897
|
+
const entities: any[] = await timedUnsafe<any[]>(dbConn, result.sql, result.params, this.execSignal, this.execPerRequest);
|
|
816
898
|
|
|
817
899
|
// Convert to Entity objects
|
|
818
900
|
const entityIds: string[] = entities.map((row: any) => row.id);
|
|
@@ -866,37 +948,39 @@ AND c.deleted_at IS NULL`;
|
|
|
866
948
|
// Get the database connection (transaction or default)
|
|
867
949
|
const dbConn = this.getDb();
|
|
868
950
|
|
|
951
|
+
// created_at/updated_at included so results can warm the component
|
|
952
|
+
// cache below with full ComponentData entries.
|
|
869
953
|
let components: any[];
|
|
870
954
|
if (shouldUseDirectPartition() && componentTypeIds.length === 1) {
|
|
871
955
|
// Single component type - use direct partition if available
|
|
872
956
|
const partitionTableName = ComponentRegistry.getPartitionTableName(componentTypeIds[0]!);
|
|
873
957
|
if (partitionTableName) {
|
|
874
|
-
components = await dbConn
|
|
875
|
-
SELECT id, entity_id, type_id, data
|
|
958
|
+
components = await timedUnsafe<any[]>(dbConn, `
|
|
959
|
+
SELECT id, entity_id, type_id, data, created_at, updated_at
|
|
876
960
|
FROM ${partitionTableName}
|
|
877
961
|
WHERE entity_id IN ${entityIdList.sql}
|
|
878
962
|
AND type_id IN ${typeIdList.sql}
|
|
879
963
|
AND deleted_at IS NULL
|
|
880
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
964
|
+
`, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
|
|
881
965
|
} else {
|
|
882
966
|
// Fallback to parent table
|
|
883
|
-
components = await dbConn
|
|
884
|
-
SELECT id, entity_id, type_id, data
|
|
967
|
+
components = await timedUnsafe<any[]>(dbConn, `
|
|
968
|
+
SELECT id, entity_id, type_id, data, created_at, updated_at
|
|
885
969
|
FROM components
|
|
886
970
|
WHERE entity_id IN ${entityIdList.sql}
|
|
887
971
|
AND type_id IN ${typeIdList.sql}
|
|
888
972
|
AND deleted_at IS NULL
|
|
889
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
973
|
+
`, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
|
|
890
974
|
}
|
|
891
975
|
} else {
|
|
892
976
|
// Multiple types or direct partition disabled - use parent table
|
|
893
|
-
components = await dbConn
|
|
894
|
-
SELECT id, entity_id, type_id, data
|
|
977
|
+
components = await timedUnsafe<any[]>(dbConn, `
|
|
978
|
+
SELECT id, entity_id, type_id, data, created_at, updated_at
|
|
895
979
|
FROM components
|
|
896
980
|
WHERE entity_id IN ${entityIdList.sql}
|
|
897
981
|
AND type_id IN ${typeIdList.sql}
|
|
898
982
|
AND deleted_at IS NULL
|
|
899
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
983
|
+
`, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
|
|
900
984
|
}
|
|
901
985
|
|
|
902
986
|
// Get metadata storage for Date deserialization
|
|
@@ -939,13 +1023,70 @@ AND c.deleted_at IS NULL`;
|
|
|
939
1023
|
// Add component to entity (using protected method)
|
|
940
1024
|
(entity as any).addComponent(component);
|
|
941
1025
|
}
|
|
1026
|
+
|
|
1027
|
+
this.warmComponentCache(components, entityIds, componentTypeIds);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Fire-and-forget warm of the L1/L2 component cache from populate()
|
|
1032
|
+
* results, so subsequent `entity.get(X)` calls (same or later request)
|
|
1033
|
+
* hit cache instead of re-querying. Previously populate() bypassed the
|
|
1034
|
+
* cache entirely — only the DataLoader read path warmed it.
|
|
1035
|
+
*
|
|
1036
|
+
* Tracked via Entity.trackCacheOp so shutdown/tests can drain it.
|
|
1037
|
+
* Skipped for large result sets to avoid hammering the cache provider
|
|
1038
|
+
* with bulk-scan output.
|
|
1039
|
+
*/
|
|
1040
|
+
private warmComponentCache(components: any[], entityIds: string[], componentTypeIds: string[]): void {
|
|
1041
|
+
const WARM_CACHE_MAX = 1000;
|
|
1042
|
+
if (this.skipComponentCache || this.trx) return;
|
|
1043
|
+
if (components.length === 0 || components.length > WARM_CACHE_MAX) return;
|
|
1044
|
+
|
|
1045
|
+
Entity.trackCacheOp((async () => {
|
|
1046
|
+
try {
|
|
1047
|
+
const { CacheManager } = await import('../core/cache/CacheManager');
|
|
1048
|
+
const cacheManager = CacheManager.getInstance();
|
|
1049
|
+
const config = cacheManager.getConfig();
|
|
1050
|
+
if (!config.enabled || !config.component?.enabled) return;
|
|
1051
|
+
|
|
1052
|
+
// Requested (entity × type) pairs let the cache tombstone
|
|
1053
|
+
// known-absent components. Only built when the pair count is
|
|
1054
|
+
// bounded — tombstoning a huge scan is not worth the writes.
|
|
1055
|
+
let requested: Array<{ entityId: string; typeId: string }> | undefined;
|
|
1056
|
+
if (entityIds.length * componentTypeIds.length <= WARM_CACHE_MAX) {
|
|
1057
|
+
requested = [];
|
|
1058
|
+
for (const entityId of entityIds) {
|
|
1059
|
+
for (const typeId of componentTypeIds) {
|
|
1060
|
+
requested.push({ entityId, typeId });
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
await cacheManager.setComponentsWriteThrough(
|
|
1066
|
+
components.map((row: any) => ({
|
|
1067
|
+
id: row.id,
|
|
1068
|
+
entityId: row.entity_id,
|
|
1069
|
+
typeId: row.type_id,
|
|
1070
|
+
data: row.data,
|
|
1071
|
+
createdAt: row.created_at,
|
|
1072
|
+
updatedAt: row.updated_at,
|
|
1073
|
+
deletedAt: null,
|
|
1074
|
+
})),
|
|
1075
|
+
requested,
|
|
1076
|
+
config.component.ttl,
|
|
1077
|
+
);
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
logger.warn({ scope: 'cache', component: 'Query', msg: 'populate() component cache warm failed', error });
|
|
1080
|
+
}
|
|
1081
|
+
})());
|
|
942
1082
|
}
|
|
943
1083
|
|
|
944
1084
|
/**
|
|
945
1085
|
* Execute query with EXPLAIN ANALYZE for performance debugging
|
|
946
1086
|
* Returns the query plan and execution statistics
|
|
947
1087
|
*/
|
|
948
|
-
public async explainAnalyze(buffers: boolean = true): Promise<string> {
|
|
1088
|
+
public async explainAnalyze(buffers: boolean = true, opts?: QueryExecOptions): Promise<string> {
|
|
1089
|
+
this.applyExecOptions(opts);
|
|
949
1090
|
// Reset context for fresh execution
|
|
950
1091
|
this.context.reset();
|
|
951
1092
|
|
|
@@ -993,14 +1134,17 @@ AND c.deleted_at IS NULL`;
|
|
|
993
1134
|
}
|
|
994
1135
|
|
|
995
1136
|
// Execute the EXPLAIN ANALYZE query
|
|
996
|
-
const explainResult = await dbConn
|
|
1137
|
+
const explainResult = await timedUnsafe<any[]>(dbConn, explainSql, result.params, this.execSignal, this.execPerRequest);
|
|
997
1138
|
|
|
998
1139
|
// Format the result
|
|
999
1140
|
return explainResult.map((row: any) => row['QUERY PLAN']).join('\n');
|
|
1000
1141
|
}
|
|
1001
1142
|
|
|
1002
1143
|
/**
|
|
1003
|
-
* Get prepared statement cache statistics
|
|
1144
|
+
* Get prepared statement cache statistics.
|
|
1145
|
+
* @deprecated The framework-level prepared statement cache is no longer
|
|
1146
|
+
* used on the query hot path (Bun SQL auto-prepares at the driver
|
|
1147
|
+
* layer). Stats remain for API compatibility and report an idle cache.
|
|
1004
1148
|
*/
|
|
1005
1149
|
public static getCacheStats() {
|
|
1006
1150
|
return preparedStatementCache.getStats();
|
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}`);
|