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
|
@@ -1,141 +1,141 @@
|
|
|
1
|
-
import type { SQL } from "bun";
|
|
2
|
-
import { logger as MainLogger } from "../core/Logger";
|
|
3
|
-
import { runWithSignal } from "./cancellable";
|
|
4
|
-
|
|
5
|
-
const logger = MainLogger.child({ scope: "db" });
|
|
6
|
-
|
|
7
|
-
const SLOW_MS = parseInt(process.env.BUNSANE_DB_SLOW_MS ?? '500', 10);
|
|
8
|
-
|
|
9
|
-
export type DataLoaderKind = 'entity' | 'component' | 'relation';
|
|
10
|
-
|
|
11
|
-
interface DbStatsInternal {
|
|
12
|
-
totalCount: number;
|
|
13
|
-
totalMs: number;
|
|
14
|
-
maxMs: number;
|
|
15
|
-
slowCount: number;
|
|
16
|
-
abortedCount: number;
|
|
17
|
-
inFlight: number;
|
|
18
|
-
inFlightMax: number;
|
|
19
|
-
dataLoaderCalls: { entity: number; component: number; relation: number };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const stats: DbStatsInternal = {
|
|
23
|
-
totalCount: 0,
|
|
24
|
-
totalMs: 0,
|
|
25
|
-
maxMs: 0,
|
|
26
|
-
slowCount: 0,
|
|
27
|
-
abortedCount: 0,
|
|
28
|
-
inFlight: 0,
|
|
29
|
-
inFlightMax: 0,
|
|
30
|
-
dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Per-request counter incremented when current request context is reachable
|
|
35
|
-
* via the (request as any).__bunsaneStats pointer. We accept that as a
|
|
36
|
-
* parameter from the call site so this module stays free of GraphQL imports.
|
|
37
|
-
*/
|
|
38
|
-
export interface PerRequestCounters {
|
|
39
|
-
dbQueryCount: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Execute `db.unsafe(sql, params)` with optional AbortSignal cancellation
|
|
44
|
-
* and roundtrip telemetry. On abort the in-flight query is cancelled via
|
|
45
|
-
* `Query.cancel()`. Total ms is recorded into module-level stats; calls
|
|
46
|
-
* over `BUNSANE_DB_SLOW_MS` increment slowCount and emit a warn log.
|
|
47
|
-
*/
|
|
48
|
-
export async function timedUnsafe<T = any>(
|
|
49
|
-
db: SQL,
|
|
50
|
-
sql: string,
|
|
51
|
-
params: any[],
|
|
52
|
-
signal?: AbortSignal,
|
|
53
|
-
perRequest?: PerRequestCounters,
|
|
54
|
-
): Promise<T> {
|
|
55
|
-
const t0 = performance.now();
|
|
56
|
-
stats.inFlight++;
|
|
57
|
-
if (stats.inFlight > stats.inFlightMax) stats.inFlightMax = stats.inFlight;
|
|
58
|
-
if (perRequest) perRequest.dbQueryCount++;
|
|
59
|
-
let aborted = false;
|
|
60
|
-
try {
|
|
61
|
-
const q = (db as any).unsafe(sql, params);
|
|
62
|
-
return await runWithSignal<T>(q, signal);
|
|
63
|
-
} catch (err) {
|
|
64
|
-
if ((err as Error)?.name === 'AbortError' || signal?.aborted) {
|
|
65
|
-
aborted = true;
|
|
66
|
-
stats.abortedCount++;
|
|
67
|
-
}
|
|
68
|
-
throw err;
|
|
69
|
-
} finally {
|
|
70
|
-
const dt = performance.now() - t0;
|
|
71
|
-
stats.inFlight--;
|
|
72
|
-
stats.totalCount++;
|
|
73
|
-
stats.totalMs += dt;
|
|
74
|
-
if (dt > stats.maxMs) stats.maxMs = dt;
|
|
75
|
-
if (SLOW_MS > 0 && dt > SLOW_MS && !aborted) {
|
|
76
|
-
stats.slowCount++;
|
|
77
|
-
logger.warn(
|
|
78
|
-
{
|
|
79
|
-
durationMs: Math.round(dt),
|
|
80
|
-
thresholdMs: SLOW_MS,
|
|
81
|
-
sqlSnippet: sql.length > 200 ? sql.slice(0, 200) + '…' : sql,
|
|
82
|
-
msg: 'Slow DB call',
|
|
83
|
-
},
|
|
84
|
-
'Slow DB call',
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Increment the per-kind DataLoader counter. Called from inside DataLoader
|
|
92
|
-
* batch functions so /metrics + access log can attribute load patterns.
|
|
93
|
-
*
|
|
94
|
-
* `perRequest` is loosely typed because RequestContext's `RequestStats`
|
|
95
|
-
* (defined in core/RequestContext.ts) extends `PerRequestCounters` with
|
|
96
|
-
* extra fields like `dataLoaderCalls`. We accept either shape here without
|
|
97
|
-
* importing the higher-level type (which would create a cycle).
|
|
98
|
-
*/
|
|
99
|
-
export function incrementDataLoaderCall(
|
|
100
|
-
kind: DataLoaderKind,
|
|
101
|
-
perRequest?: PerRequestCounters | { dataLoaderCalls?: { entity: number; component: number; relation: number } },
|
|
102
|
-
): void {
|
|
103
|
-
stats.dataLoaderCalls[kind]++;
|
|
104
|
-
const dlc = (perRequest as any)?.dataLoaderCalls;
|
|
105
|
-
if (dlc) dlc[kind]++;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Snapshot of accumulated DB stats for the /metrics endpoint.
|
|
110
|
-
*/
|
|
111
|
-
export function getDbStats() {
|
|
112
|
-
const avgMs = stats.totalCount > 0 ? stats.totalMs / stats.totalCount : 0;
|
|
113
|
-
return {
|
|
114
|
-
totalCount: stats.totalCount,
|
|
115
|
-
totalMs: Math.round(stats.totalMs),
|
|
116
|
-
maxMs: Math.round(stats.maxMs),
|
|
117
|
-
avgMs: Number(avgMs.toFixed(2)),
|
|
118
|
-
slowCount: stats.slowCount,
|
|
119
|
-
abortedCount: stats.abortedCount,
|
|
120
|
-
inFlight: stats.inFlight,
|
|
121
|
-
inFlightMax: stats.inFlightMax,
|
|
122
|
-
slowThresholdMs: SLOW_MS,
|
|
123
|
-
dataLoaderCalls: { ...stats.dataLoaderCalls },
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Reset counters. Intended for tests only.
|
|
129
|
-
*/
|
|
130
|
-
export function resetDbStats(): void {
|
|
131
|
-
stats.totalCount = 0;
|
|
132
|
-
stats.totalMs = 0;
|
|
133
|
-
stats.maxMs = 0;
|
|
134
|
-
stats.slowCount = 0;
|
|
135
|
-
stats.abortedCount = 0;
|
|
136
|
-
stats.inFlight = 0;
|
|
137
|
-
stats.inFlightMax = 0;
|
|
138
|
-
stats.dataLoaderCalls.entity = 0;
|
|
139
|
-
stats.dataLoaderCalls.component = 0;
|
|
140
|
-
stats.dataLoaderCalls.relation = 0;
|
|
141
|
-
}
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { logger as MainLogger } from "../core/Logger";
|
|
3
|
+
import { runWithSignal } from "./cancellable";
|
|
4
|
+
|
|
5
|
+
const logger = MainLogger.child({ scope: "db" });
|
|
6
|
+
|
|
7
|
+
const SLOW_MS = parseInt(process.env.BUNSANE_DB_SLOW_MS ?? '500', 10);
|
|
8
|
+
|
|
9
|
+
export type DataLoaderKind = 'entity' | 'component' | 'relation';
|
|
10
|
+
|
|
11
|
+
interface DbStatsInternal {
|
|
12
|
+
totalCount: number;
|
|
13
|
+
totalMs: number;
|
|
14
|
+
maxMs: number;
|
|
15
|
+
slowCount: number;
|
|
16
|
+
abortedCount: number;
|
|
17
|
+
inFlight: number;
|
|
18
|
+
inFlightMax: number;
|
|
19
|
+
dataLoaderCalls: { entity: number; component: number; relation: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stats: DbStatsInternal = {
|
|
23
|
+
totalCount: 0,
|
|
24
|
+
totalMs: 0,
|
|
25
|
+
maxMs: 0,
|
|
26
|
+
slowCount: 0,
|
|
27
|
+
abortedCount: 0,
|
|
28
|
+
inFlight: 0,
|
|
29
|
+
inFlightMax: 0,
|
|
30
|
+
dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per-request counter incremented when current request context is reachable
|
|
35
|
+
* via the (request as any).__bunsaneStats pointer. We accept that as a
|
|
36
|
+
* parameter from the call site so this module stays free of GraphQL imports.
|
|
37
|
+
*/
|
|
38
|
+
export interface PerRequestCounters {
|
|
39
|
+
dbQueryCount: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Execute `db.unsafe(sql, params)` with optional AbortSignal cancellation
|
|
44
|
+
* and roundtrip telemetry. On abort the in-flight query is cancelled via
|
|
45
|
+
* `Query.cancel()`. Total ms is recorded into module-level stats; calls
|
|
46
|
+
* over `BUNSANE_DB_SLOW_MS` increment slowCount and emit a warn log.
|
|
47
|
+
*/
|
|
48
|
+
export async function timedUnsafe<T = any>(
|
|
49
|
+
db: SQL,
|
|
50
|
+
sql: string,
|
|
51
|
+
params: any[],
|
|
52
|
+
signal?: AbortSignal,
|
|
53
|
+
perRequest?: PerRequestCounters,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const t0 = performance.now();
|
|
56
|
+
stats.inFlight++;
|
|
57
|
+
if (stats.inFlight > stats.inFlightMax) stats.inFlightMax = stats.inFlight;
|
|
58
|
+
if (perRequest) perRequest.dbQueryCount++;
|
|
59
|
+
let aborted = false;
|
|
60
|
+
try {
|
|
61
|
+
const q = (db as any).unsafe(sql, params);
|
|
62
|
+
return await runWithSignal<T>(q, signal);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if ((err as Error)?.name === 'AbortError' || signal?.aborted) {
|
|
65
|
+
aborted = true;
|
|
66
|
+
stats.abortedCount++;
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
} finally {
|
|
70
|
+
const dt = performance.now() - t0;
|
|
71
|
+
stats.inFlight--;
|
|
72
|
+
stats.totalCount++;
|
|
73
|
+
stats.totalMs += dt;
|
|
74
|
+
if (dt > stats.maxMs) stats.maxMs = dt;
|
|
75
|
+
if (SLOW_MS > 0 && dt > SLOW_MS && !aborted) {
|
|
76
|
+
stats.slowCount++;
|
|
77
|
+
logger.warn(
|
|
78
|
+
{
|
|
79
|
+
durationMs: Math.round(dt),
|
|
80
|
+
thresholdMs: SLOW_MS,
|
|
81
|
+
sqlSnippet: sql.length > 200 ? sql.slice(0, 200) + '…' : sql,
|
|
82
|
+
msg: 'Slow DB call',
|
|
83
|
+
},
|
|
84
|
+
'Slow DB call',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Increment the per-kind DataLoader counter. Called from inside DataLoader
|
|
92
|
+
* batch functions so /metrics + access log can attribute load patterns.
|
|
93
|
+
*
|
|
94
|
+
* `perRequest` is loosely typed because RequestContext's `RequestStats`
|
|
95
|
+
* (defined in core/RequestContext.ts) extends `PerRequestCounters` with
|
|
96
|
+
* extra fields like `dataLoaderCalls`. We accept either shape here without
|
|
97
|
+
* importing the higher-level type (which would create a cycle).
|
|
98
|
+
*/
|
|
99
|
+
export function incrementDataLoaderCall(
|
|
100
|
+
kind: DataLoaderKind,
|
|
101
|
+
perRequest?: PerRequestCounters | { dataLoaderCalls?: { entity: number; component: number; relation: number } },
|
|
102
|
+
): void {
|
|
103
|
+
stats.dataLoaderCalls[kind]++;
|
|
104
|
+
const dlc = (perRequest as any)?.dataLoaderCalls;
|
|
105
|
+
if (dlc) dlc[kind]++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Snapshot of accumulated DB stats for the /metrics endpoint.
|
|
110
|
+
*/
|
|
111
|
+
export function getDbStats() {
|
|
112
|
+
const avgMs = stats.totalCount > 0 ? stats.totalMs / stats.totalCount : 0;
|
|
113
|
+
return {
|
|
114
|
+
totalCount: stats.totalCount,
|
|
115
|
+
totalMs: Math.round(stats.totalMs),
|
|
116
|
+
maxMs: Math.round(stats.maxMs),
|
|
117
|
+
avgMs: Number(avgMs.toFixed(2)),
|
|
118
|
+
slowCount: stats.slowCount,
|
|
119
|
+
abortedCount: stats.abortedCount,
|
|
120
|
+
inFlight: stats.inFlight,
|
|
121
|
+
inFlightMax: stats.inFlightMax,
|
|
122
|
+
slowThresholdMs: SLOW_MS,
|
|
123
|
+
dataLoaderCalls: { ...stats.dataLoaderCalls },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reset counters. Intended for tests only.
|
|
129
|
+
*/
|
|
130
|
+
export function resetDbStats(): void {
|
|
131
|
+
stats.totalCount = 0;
|
|
132
|
+
stats.totalMs = 0;
|
|
133
|
+
stats.maxMs = 0;
|
|
134
|
+
stats.slowCount = 0;
|
|
135
|
+
stats.abortedCount = 0;
|
|
136
|
+
stats.inFlight = 0;
|
|
137
|
+
stats.inFlightMax = 0;
|
|
138
|
+
stats.dataLoaderCalls.entity = 0;
|
|
139
|
+
stats.dataLoaderCalls.component = 0;
|
|
140
|
+
stats.dataLoaderCalls.relation = 0;
|
|
141
|
+
}
|
package/endpoints/archetypes.ts
CHANGED
|
@@ -319,19 +319,13 @@ export async function handleStudioArcheTypeDeleteRequest(
|
|
|
319
319
|
.join(", ");
|
|
320
320
|
|
|
321
321
|
// Delete in correct order to avoid foreign key constraint violations
|
|
322
|
-
// 1. Delete from
|
|
323
|
-
await db.unsafe(
|
|
324
|
-
`DELETE FROM entity_components WHERE entity_id IN (${idPlaceholders})`,
|
|
325
|
-
entityIds
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
// 2. Delete from components
|
|
322
|
+
// 1. Delete from components (membership source of truth)
|
|
329
323
|
await db.unsafe(
|
|
330
324
|
`DELETE FROM components WHERE entity_id IN (${idPlaceholders})`,
|
|
331
325
|
entityIds
|
|
332
326
|
);
|
|
333
327
|
|
|
334
|
-
//
|
|
328
|
+
// 2. Delete from entities
|
|
335
329
|
await db.unsafe(
|
|
336
330
|
`DELETE FROM entities WHERE id IN (${idPlaceholders})`,
|
|
337
331
|
entityIds
|
package/endpoints/tables.ts
CHANGED
|
@@ -179,7 +179,12 @@ export async function handleStudioTableDeleteRequest(
|
|
|
179
179
|
|
|
180
180
|
export async function handleGetTables(): Promise<Response> {
|
|
181
181
|
try {
|
|
182
|
-
//
|
|
182
|
+
// Exclude framework-internal tables and the legacy entity_components table.
|
|
183
|
+
// entity_components is no longer written by the framework (Phase 3 of
|
|
184
|
+
// docs/ENTITY_COMPONENTS_REMOVAL_PLAN.md) but may still exist as an orphan
|
|
185
|
+
// in upgraded databases. Keeping it out of the Studio listing avoids
|
|
186
|
+
// exposing a confusingly schema'd legacy table with no ECS UI support.
|
|
187
|
+
// Users are directed to drop it via the startup orphan-notice log.
|
|
183
188
|
const ecsTables = ['components', 'entities', 'entity_components', 'spatial_ref_sys'];
|
|
184
189
|
const ecsTablePlaceholders = ecsTables.map((_, index) => `$${index + 1}`).join(", ");
|
|
185
190
|
|
package/gql/index.ts
CHANGED
|
@@ -116,7 +116,7 @@ const maskError = (error: any, message: string): GraphQLError => {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
// Pass through known application-level GraphQL error codes
|
|
119
|
-
const isGQLError = (e: any): e is
|
|
119
|
+
const isGQLError = (e: any): e is { message: string; extensions?: Record<string, unknown> } =>
|
|
120
120
|
e instanceof GraphQLError ||
|
|
121
121
|
(e !== null && typeof e === 'object' && 'extensions' in e && 'message' in e && typeof e.message === 'string');
|
|
122
122
|
const knownCodes = ['FORBIDDEN', 'NOT_FOUND', 'BAD_USER_INPUT', 'BAD_REQUEST'];
|
|
@@ -18,24 +18,45 @@ export class ResolverGeneratorVisitor extends GraphVisitor {
|
|
|
18
18
|
this.services = services;
|
|
19
19
|
this.resolverBuilder = new ResolverBuilder();
|
|
20
20
|
|
|
21
|
-
// Add Date scalar resolver
|
|
21
|
+
// Add Date scalar resolver.
|
|
22
|
+
// Safety net: gqloom's `z.date()` currently maps to GraphQLString
|
|
23
|
+
// (see @gqloom/zod isZodDate → GraphQLString), so this custom Date
|
|
24
|
+
// scalar is rarely wired by the auto-generated archetype schema.
|
|
25
|
+
// Component-prop leaf resolvers normalize Date → ISO string upstream
|
|
26
|
+
// (core/archetype/fieldResolvers.ts) so GraphQLString coercion does
|
|
27
|
+
// not call Date.valueOf() and emit epoch ms. We still harden this
|
|
28
|
+
// serializer to accept Date, number, and numeric-string inputs in
|
|
29
|
+
// case a downstream user types a field as the `Date` scalar
|
|
30
|
+
// directly.
|
|
22
31
|
this.resolverBuilder.addScalarResolver('Date', {
|
|
23
32
|
serialize: (value: any) => {
|
|
24
|
-
if (value
|
|
25
|
-
|
|
33
|
+
if (value === null || value === undefined) return value;
|
|
34
|
+
if (value instanceof Date) return value.toISOString();
|
|
35
|
+
if (typeof value === 'number') return new Date(value).toISOString();
|
|
36
|
+
if (typeof value === 'string') {
|
|
37
|
+
if (/^\d+$/.test(value)) {
|
|
38
|
+
return new Date(Number(value)).toISOString();
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
26
41
|
}
|
|
27
|
-
|
|
42
|
+
throw new Error(`Date scalar cannot serialize ${typeof value}`);
|
|
28
43
|
},
|
|
29
44
|
parseValue: (value: any) => {
|
|
30
45
|
if (typeof value === 'string') {
|
|
31
46
|
return new Date(value);
|
|
32
47
|
}
|
|
48
|
+
if (typeof value === 'number') {
|
|
49
|
+
return new Date(value);
|
|
50
|
+
}
|
|
33
51
|
return value;
|
|
34
52
|
},
|
|
35
53
|
parseLiteral: (ast: any) => {
|
|
36
54
|
if (ast.kind === 'StringValue') {
|
|
37
55
|
return new Date(ast.value);
|
|
38
56
|
}
|
|
57
|
+
if (ast.kind === 'IntValue') {
|
|
58
|
+
return new Date(Number(ast.value));
|
|
59
|
+
}
|
|
39
60
|
return null;
|
|
40
61
|
}
|
|
41
62
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunsane",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"author": {
|
|
5
5
|
"name": "yaaruu"
|
|
6
6
|
},
|
|
@@ -14,6 +14,27 @@
|
|
|
14
14
|
],
|
|
15
15
|
"module": "index.ts",
|
|
16
16
|
"type": "module",
|
|
17
|
+
"files": [
|
|
18
|
+
"index.ts",
|
|
19
|
+
"config",
|
|
20
|
+
"core",
|
|
21
|
+
"database",
|
|
22
|
+
"endpoints",
|
|
23
|
+
"gql",
|
|
24
|
+
"plugins",
|
|
25
|
+
"query",
|
|
26
|
+
"rest",
|
|
27
|
+
"scheduler",
|
|
28
|
+
"service",
|
|
29
|
+
"storage",
|
|
30
|
+
"studio/dist",
|
|
31
|
+
"swagger",
|
|
32
|
+
"types",
|
|
33
|
+
"upload",
|
|
34
|
+
"utils",
|
|
35
|
+
"tsconfig.json",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
17
38
|
"scripts": {
|
|
18
39
|
"build": "bun run build:studio && tsc",
|
|
19
40
|
"build:studio": "cd studio && bun install && bun run build",
|
package/query/CTENode.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { QueryNode } from "./QueryNode";
|
|
2
2
|
import type { QueryResult } from "./QueryNode";
|
|
3
3
|
import { QueryContext } from "./QueryContext";
|
|
4
|
+
import { getMembershipTable } from "./membershipSource";
|
|
4
5
|
|
|
5
6
|
export class CTENode extends QueryNode {
|
|
6
7
|
public execute(context: QueryContext): QueryResult {
|
|
7
8
|
// Generate CTE for base entity filtering
|
|
8
9
|
const componentIds = Array.from(context.componentIds);
|
|
9
10
|
const excludedIds = Array.from(context.excludedComponentIds);
|
|
11
|
+
const membershipTable = getMembershipTable();
|
|
10
12
|
|
|
11
13
|
if (componentIds.length === 0) {
|
|
12
14
|
throw new Error("CTENode requires at least one component type to filter on");
|
|
@@ -26,7 +28,7 @@ export class CTENode extends QueryNode {
|
|
|
26
28
|
if (excludedIds.length > 0) {
|
|
27
29
|
const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
28
30
|
exclusionCondition = ` AND NOT EXISTS (
|
|
29
|
-
SELECT 1 FROM
|
|
31
|
+
SELECT 1 FROM ${membershipTable} ec_ex
|
|
30
32
|
WHERE ec_ex.entity_id = ec.entity_id
|
|
31
33
|
AND ec_ex.type_id IN (${excludedPlaceholders})
|
|
32
34
|
AND ec_ex.deleted_at IS NULL
|
|
@@ -45,7 +47,7 @@ export class CTENode extends QueryNode {
|
|
|
45
47
|
// Single component - simple query, no INTERSECT needed
|
|
46
48
|
const paramIdx = context.addParam(componentIds[0]);
|
|
47
49
|
cteSql += ` SELECT DISTINCT ec.entity_id\n`;
|
|
48
|
-
cteSql += ` FROM
|
|
50
|
+
cteSql += ` FROM ${membershipTable} ec\n`;
|
|
49
51
|
cteSql += ` WHERE ec.type_id = $${paramIdx}::text\n`;
|
|
50
52
|
cteSql += ` AND ec.deleted_at IS NULL\n`;
|
|
51
53
|
if (cursorCondition) cteSql += ` ${cursorCondition.trim()}\n`;
|
|
@@ -57,7 +59,7 @@ export class CTENode extends QueryNode {
|
|
|
57
59
|
// then efficiently merge results, avoiding Cartesian product explosion
|
|
58
60
|
const intersectQueries = componentIds.map((compId) => {
|
|
59
61
|
const paramIdx = context.addParam(compId);
|
|
60
|
-
let subquery = `SELECT ec.entity_id FROM
|
|
62
|
+
let subquery = `SELECT ec.entity_id FROM ${membershipTable} ec WHERE ec.type_id = $${paramIdx}::text AND ec.deleted_at IS NULL`;
|
|
61
63
|
// Add cursor/exclusion conditions to each subquery for efficiency
|
|
62
64
|
if (cursorCondition) subquery += cursorCondition;
|
|
63
65
|
if (exclusionCondition) subquery += exclusionCondition;
|