bunsane 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +8 -7
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +25 -10
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. package/tests/utils/test-context.ts +0 -149
@@ -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
+ }
@@ -12,6 +12,8 @@ export function inList<T>(values: T[], paramIndex: number): { sql: string, param
12
12
 
13
13
  if (filteredValues.length === 0) return { sql: '()', params: [], newParamIndex: paramIndex };
14
14
 
15
- const placeholders = Array.from({length: filteredValues.length}, (_, i) => `$${paramIndex + i}`).join(', ');
15
+ const n = filteredValues.length;
16
+ let placeholders = '';
17
+ for (let i = 0; i < n; i++) { placeholders += (i ? ', $' : '$') + (paramIndex + i); }
16
18
  return { sql: `(${placeholders})`, params: filteredValues, newParamIndex: paramIndex + filteredValues.length };
17
19
  }
@@ -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 entity_components (junction table)
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
- // 3. Delete from entities
328
+ // 2. Delete from entities
335
329
  await db.unsafe(
336
330
  `DELETE FROM entities WHERE id IN (${idPlaceholders})`,
337
331
  entityIds
@@ -179,7 +179,12 @@ export async function handleStudioTableDeleteRequest(
179
179
 
180
180
  export async function handleGetTables(): Promise<Response> {
181
181
  try {
182
- // Fetch all tables except ECS tables
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 GraphQLError =>
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'];
@@ -406,6 +406,12 @@ export function isSchemaInput(
406
406
 
407
407
  // ─── Validation ─────────────────────────────────────────────────────────────────
408
408
 
409
+ // Keyed by schema object identity. Safe only when field builders inside the
410
+ // schema are not mutated (via .required()/.optional()/.nullable()) after the
411
+ // first validateInput call for that schema. Module-level schema constants
412
+ // satisfy this constraint; inline schemas constructed per-call simply miss.
413
+ const _zodCache = new WeakMap<object, ZodType>();
414
+
409
415
  export class SchemaValidationError extends Error {
410
416
  readonly fieldErrors: Array<{ path: string; message: string }>;
411
417
 
@@ -424,11 +430,16 @@ export function validateInput<T extends Record<string, SchemaType>>(
424
430
  data: unknown,
425
431
  operationName: string = "input",
426
432
  ): InferInput<T> {
427
- const zodShape: Record<string, ZodType> = {};
428
- for (const [key, field] of Object.entries(schema)) {
429
- zodShape[key] = field.toZod();
433
+ let zodObject = _zodCache.get(schema);
434
+ if (!zodObject) {
435
+ const zodShape: Record<string, ZodType> = {};
436
+ for (const [key, field] of Object.entries(schema)) {
437
+ zodShape[key] = field.toZod();
438
+ }
439
+ zodObject = z.object(zodShape);
440
+ _zodCache.set(schema, zodObject);
430
441
  }
431
- const result = z.object(zodShape).safeParse(data);
442
+ const result = zodObject.safeParse(data);
432
443
  if (result.success) {
433
444
  return result.data as InferInput<T>;
434
445
  }
@@ -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 instanceof Date) {
25
- return value.toISOString();
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
- return value;
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.2",
3
+ "version": "0.5.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 entity_components ec_ex
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 entity_components ec\n`;
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 entity_components ec WHERE ec.type_id = $${paramIdx}::text AND ec.deleted_at IS NULL`;
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;