bunsane 0.1.4 → 0.2.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 (257) hide show
  1. package/.claude/settings.local.json +47 -0
  2. package/.claude/skills/update-memory.md +74 -0
  3. package/.prettierrc +4 -0
  4. package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
  5. package/.serena/memories/architecture.md +154 -0
  6. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
  7. package/.serena/memories/code_style_and_conventions.md +76 -0
  8. package/.serena/memories/project_overview.md +43 -0
  9. package/.serena/memories/schema-dsl-plan.md +107 -0
  10. package/.serena/memories/suggested_commands.md +80 -0
  11. package/.serena/memories/typescript-compilation-status.md +54 -0
  12. package/.serena/project.yml +114 -0
  13. package/TODO.md +1 -7
  14. package/bun.lock +150 -4
  15. package/bunfig.toml +10 -0
  16. package/config/cache.config.ts +77 -0
  17. package/config/upload.config.ts +4 -5
  18. package/core/App.ts +870 -123
  19. package/core/ArcheType.ts +2268 -377
  20. package/core/BatchLoader.ts +181 -71
  21. package/core/Config.ts +153 -0
  22. package/core/Decorators.ts +4 -1
  23. package/core/Entity.ts +621 -92
  24. package/core/EntityHookManager.ts +1 -1
  25. package/core/EntityInterface.ts +3 -1
  26. package/core/EntityManager.ts +1 -13
  27. package/core/ErrorHandler.ts +8 -2
  28. package/core/Logger.ts +9 -0
  29. package/core/Middleware.ts +34 -0
  30. package/core/RequestContext.ts +5 -1
  31. package/core/RequestLoaders.ts +227 -93
  32. package/core/SchedulerManager.ts +193 -52
  33. package/core/cache/CacheAnalytics.ts +399 -0
  34. package/core/cache/CacheFactory.ts +145 -0
  35. package/core/cache/CacheManager.ts +520 -0
  36. package/core/cache/CacheProvider.ts +34 -0
  37. package/core/cache/CacheWarmer.ts +157 -0
  38. package/core/cache/CompressionUtils.ts +110 -0
  39. package/core/cache/MemoryCache.ts +251 -0
  40. package/core/cache/MultiLevelCache.ts +180 -0
  41. package/core/cache/NoOpCache.ts +53 -0
  42. package/core/cache/RedisCache.ts +464 -0
  43. package/core/cache/TTLStrategy.ts +254 -0
  44. package/core/cache/index.ts +6 -0
  45. package/core/components/BaseComponent.ts +120 -0
  46. package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
  47. package/core/components/Decorators.ts +88 -0
  48. package/core/components/Interfaces.ts +7 -0
  49. package/core/components/index.ts +5 -0
  50. package/core/decorators/EntityHooks.ts +0 -3
  51. package/core/decorators/IndexedField.ts +26 -0
  52. package/core/decorators/ScheduledTask.ts +0 -47
  53. package/core/events/EntityLifecycleEvents.ts +1 -1
  54. package/core/health.ts +112 -0
  55. package/core/metadata/definitions/ArcheType.ts +14 -0
  56. package/core/metadata/definitions/Component.ts +9 -0
  57. package/core/metadata/definitions/gqlObject.ts +1 -1
  58. package/core/metadata/index.ts +42 -1
  59. package/core/metadata/metadata-storage.ts +28 -2
  60. package/core/middleware/AccessLog.ts +59 -0
  61. package/core/middleware/RequestId.ts +38 -0
  62. package/core/middleware/SecurityHeaders.ts +62 -0
  63. package/core/middleware/index.ts +3 -0
  64. package/core/scheduler/DistributedLock.ts +266 -0
  65. package/core/scheduler/index.ts +15 -0
  66. package/core/validateEnv.ts +92 -0
  67. package/database/DatabaseHelper.ts +416 -40
  68. package/database/IndexingStrategy.ts +342 -0
  69. package/database/PreparedStatementCache.ts +226 -0
  70. package/database/index.ts +32 -7
  71. package/database/sqlHelpers.ts +14 -2
  72. package/endpoints/archetypes.ts +362 -0
  73. package/endpoints/components.ts +58 -0
  74. package/endpoints/entity.ts +80 -0
  75. package/endpoints/index.ts +27 -0
  76. package/endpoints/query.ts +93 -0
  77. package/endpoints/stats.ts +76 -0
  78. package/endpoints/tables.ts +212 -0
  79. package/endpoints/types.ts +155 -0
  80. package/gql/ArchetypeOperations.ts +32 -86
  81. package/gql/Generator.ts +27 -315
  82. package/gql/GeneratorV2.ts +37 -0
  83. package/gql/builders/InputTypeBuilder.ts +99 -0
  84. package/gql/builders/ResolverBuilder.ts +234 -0
  85. package/gql/builders/TypeDefBuilder.ts +105 -0
  86. package/gql/builders/index.ts +3 -0
  87. package/gql/decorators/Upload.ts +1 -1
  88. package/gql/depthLimit.ts +85 -0
  89. package/gql/graph/GraphNode.ts +224 -0
  90. package/gql/graph/SchemaGraph.ts +278 -0
  91. package/gql/helpers.ts +8 -2
  92. package/gql/index.ts +56 -4
  93. package/gql/middleware.ts +79 -0
  94. package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
  95. package/gql/orchestration/index.ts +1 -0
  96. package/gql/scanner/ServiceScanner.ts +347 -0
  97. package/gql/schema/index.ts +458 -0
  98. package/gql/strategies/TypeGenerationStrategy.ts +329 -0
  99. package/gql/types.ts +1 -0
  100. package/gql/utils/TypeSignature.ts +220 -0
  101. package/gql/utils/index.ts +1 -0
  102. package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
  103. package/gql/visitors/DeduplicationVisitor.ts +82 -0
  104. package/gql/visitors/GraphVisitor.ts +78 -0
  105. package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
  106. package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
  107. package/gql/visitors/TypeCollectorVisitor.ts +79 -0
  108. package/gql/visitors/VisitorComposer.ts +96 -0
  109. package/gql/visitors/index.ts +7 -0
  110. package/package.json +59 -37
  111. package/plugins/index.ts +2 -2
  112. package/query/CTENode.ts +97 -0
  113. package/query/ComponentInclusionNode.ts +689 -0
  114. package/query/FilterBuilder.ts +127 -0
  115. package/query/FilterBuilderRegistry.ts +202 -0
  116. package/query/OrNode.ts +517 -0
  117. package/query/OrQuery.ts +42 -0
  118. package/query/Query.ts +1022 -0
  119. package/query/QueryContext.ts +170 -0
  120. package/query/QueryDAG.ts +122 -0
  121. package/query/QueryNode.ts +65 -0
  122. package/query/SourceNode.ts +53 -0
  123. package/query/builders/FullTextSearchBuilder.ts +236 -0
  124. package/query/index.ts +21 -0
  125. package/scheduler/index.ts +40 -8
  126. package/service/Service.ts +2 -1
  127. package/service/ServiceRegistry.ts +6 -5
  128. package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
  129. package/storage/S3StorageProvider.ts +316 -0
  130. package/{core/storage → storage}/StorageProvider.ts +7 -3
  131. package/studio/bun.lock +482 -0
  132. package/studio/index.html +13 -0
  133. package/studio/package.json +39 -0
  134. package/studio/postcss.config.js +6 -0
  135. package/studio/src/components/DataTable.tsx +211 -0
  136. package/studio/src/components/Layout.tsx +13 -0
  137. package/studio/src/components/PageContainer.tsx +9 -0
  138. package/studio/src/components/PageHeader.tsx +13 -0
  139. package/studio/src/components/SearchBar.tsx +57 -0
  140. package/studio/src/components/Sidebar.tsx +294 -0
  141. package/studio/src/components/ui/button.tsx +56 -0
  142. package/studio/src/components/ui/checkbox.tsx +26 -0
  143. package/studio/src/components/ui/input.tsx +25 -0
  144. package/studio/src/hooks/useDataTable.ts +131 -0
  145. package/studio/src/index.css +36 -0
  146. package/studio/src/lib/api.ts +186 -0
  147. package/studio/src/lib/utils.ts +13 -0
  148. package/studio/src/main.tsx +17 -0
  149. package/studio/src/pages/ArcheType.tsx +239 -0
  150. package/studio/src/pages/Components.tsx +124 -0
  151. package/studio/src/pages/EntityInspector.tsx +302 -0
  152. package/studio/src/pages/QueryRunner.tsx +246 -0
  153. package/studio/src/pages/Table.tsx +94 -0
  154. package/studio/src/pages/Welcome.tsx +241 -0
  155. package/studio/src/routes.tsx +45 -0
  156. package/studio/src/store/archeTypeSettings.ts +30 -0
  157. package/studio/src/store/studio.ts +65 -0
  158. package/studio/src/utils/columnHelpers.tsx +114 -0
  159. package/studio/studio-instructions.md +81 -0
  160. package/studio/tailwind.config.js +77 -0
  161. package/studio/tsconfig.json +24 -0
  162. package/studio/utils.ts +54 -0
  163. package/studio/vite.config.js +19 -0
  164. package/swagger/generator.ts +1 -1
  165. package/tests/e2e/http.test.ts +126 -0
  166. package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
  167. package/tests/fixtures/components/TestOrder.ts +23 -0
  168. package/tests/fixtures/components/TestProduct.ts +23 -0
  169. package/tests/fixtures/components/TestUser.ts +20 -0
  170. package/tests/fixtures/components/index.ts +6 -0
  171. package/tests/graphql/SchemaGeneration.test.ts +90 -0
  172. package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
  173. package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
  174. package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
  175. package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
  176. package/tests/integration/entity/Entity.persistence.test.ts +333 -0
  177. package/tests/integration/query/Query.exec.test.ts +523 -0
  178. package/tests/pglite-setup.ts +61 -0
  179. package/tests/setup.ts +164 -0
  180. package/tests/stress/BenchmarkRunner.ts +203 -0
  181. package/tests/stress/DataSeeder.ts +190 -0
  182. package/tests/stress/StressTestReporter.ts +229 -0
  183. package/tests/stress/cursor-perf-test.ts +171 -0
  184. package/tests/stress/fixtures/StressTestComponents.ts +58 -0
  185. package/tests/stress/index.ts +7 -0
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
  187. package/tests/unit/BatchLoader.test.ts +82 -0
  188. package/tests/unit/archetype/ArcheType.test.ts +107 -0
  189. package/tests/unit/cache/CacheManager.test.ts +347 -0
  190. package/tests/unit/cache/MemoryCache.test.ts +260 -0
  191. package/tests/unit/cache/RedisCache.test.ts +411 -0
  192. package/tests/unit/entity/Entity.components.test.ts +244 -0
  193. package/tests/unit/entity/Entity.test.ts +345 -0
  194. package/tests/unit/gql/depthLimit.test.ts +203 -0
  195. package/tests/unit/gql/operationMiddleware.test.ts +293 -0
  196. package/tests/unit/health/Health.test.ts +129 -0
  197. package/tests/unit/middleware/AccessLog.test.ts +37 -0
  198. package/tests/unit/middleware/Middleware.test.ts +98 -0
  199. package/tests/unit/middleware/RequestId.test.ts +54 -0
  200. package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
  201. package/tests/unit/query/FilterBuilder.test.ts +111 -0
  202. package/tests/unit/query/Query.test.ts +308 -0
  203. package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
  204. package/tests/unit/schema/schema-integration.test.ts +426 -0
  205. package/tests/unit/schema/schema.test.ts +580 -0
  206. package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
  207. package/tests/unit/upload/RestUpload.test.ts +267 -0
  208. package/tests/unit/validateEnv.test.ts +82 -0
  209. package/tests/utils/entity-tracker.ts +57 -0
  210. package/tests/utils/index.ts +13 -0
  211. package/tests/utils/test-context.ts +149 -0
  212. package/tsconfig.json +5 -1
  213. package/types/archetype.types.ts +6 -0
  214. package/types/hooks.types.ts +1 -1
  215. package/types/query.types.ts +110 -0
  216. package/types/scheduler.types.ts +68 -7
  217. package/types/upload.types.ts +1 -0
  218. package/{core → upload}/FileValidator.ts +10 -1
  219. package/upload/RestUpload.ts +130 -0
  220. package/{core/components → upload}/UploadComponent.ts +11 -11
  221. package/{core → upload}/UploadManager.ts +3 -3
  222. package/upload/index.ts +23 -7
  223. package/utils/UploadHelper.ts +27 -6
  224. package/utils/cronParser.ts +16 -6
  225. package/.github/workflows/deploy-docs.yml +0 -57
  226. package/core/Components.ts +0 -202
  227. package/core/EntityCache.ts +0 -15
  228. package/core/Query.ts +0 -880
  229. package/docs/README.md +0 -149
  230. package/docs/_coverpage.md +0 -36
  231. package/docs/_sidebar.md +0 -23
  232. package/docs/api/core.md +0 -568
  233. package/docs/api/hooks.md +0 -554
  234. package/docs/api/index.md +0 -222
  235. package/docs/api/query.md +0 -678
  236. package/docs/api/service.md +0 -744
  237. package/docs/core-concepts/archetypes.md +0 -512
  238. package/docs/core-concepts/components.md +0 -498
  239. package/docs/core-concepts/entity.md +0 -314
  240. package/docs/core-concepts/hooks.md +0 -683
  241. package/docs/core-concepts/query.md +0 -588
  242. package/docs/core-concepts/services.md +0 -647
  243. package/docs/examples/code-examples.md +0 -425
  244. package/docs/getting-started.md +0 -337
  245. package/docs/index.html +0 -97
  246. package/tests/bench/insert.bench.ts +0 -60
  247. package/tests/bench/relations.bench.ts +0 -270
  248. package/tests/bench/sorting.bench.ts +0 -416
  249. package/tests/component-hooks-simple.test.ts +0 -117
  250. package/tests/component-hooks.test.ts +0 -1461
  251. package/tests/component.test.ts +0 -339
  252. package/tests/errorHandling.test.ts +0 -155
  253. package/tests/hooks.test.ts +0 -667
  254. package/tests/query-sorting.test.ts +0 -101
  255. package/tests/query.test.ts +0 -81
  256. package/tests/relations.test.ts +0 -170
  257. package/tests/scheduler.test.ts +0 -724
@@ -0,0 +1,362 @@
1
+ import { getSerializedMetadataStorage } from "../core/metadata";
2
+ import { findIndicatorComponentName } from "../studio/utils";
3
+ import db from "../database";
4
+ import type {
5
+ StudioArcheTypeQueryParams,
6
+ StudioArcheTypeResponse,
7
+ DeleteArcheTypeEntitiesRequest,
8
+ DeleteResponse,
9
+ ArcheTypeField,
10
+ ArcheTypeEntityRecord,
11
+ } from "./types";
12
+
13
+ export async function handleStudioArcheTypeRecordsRequest(
14
+ archeTypeName: string,
15
+ params: StudioArcheTypeQueryParams = {}
16
+ ): Promise<Response> {
17
+ const limit = Math.min(Math.max(params.limit ?? 50, 1), 1000);
18
+ const offset = Math.max(params.offset ?? 0, 0);
19
+ const searchTerm = params.search ?? "";
20
+ const includeDeleted = params.include_deleted ?? false;
21
+
22
+ // Conditional filter: include or exclude soft-deleted rows
23
+ const deletedFilter = includeDeleted ? "" : "AND c.deleted_at IS NULL";
24
+ const deletedFilterBare = includeDeleted ? "" : "AND deleted_at IS NULL";
25
+
26
+ try {
27
+ const metadataStorage = getSerializedMetadataStorage();
28
+ const archeTypeFields: ArcheTypeField[] | undefined =
29
+ metadataStorage.archeTypes[archeTypeName];
30
+
31
+ if (!archeTypeFields || archeTypeFields.length === 0) {
32
+ return new Response(
33
+ JSON.stringify({
34
+ error: `ArcheType '${archeTypeName}' not found`,
35
+ }),
36
+ {
37
+ status: 404,
38
+ headers: { "Content-Type": "application/json" },
39
+ }
40
+ );
41
+ }
42
+
43
+ const indicatorComponentName = findIndicatorComponentName(
44
+ archeTypeName,
45
+ archeTypeFields
46
+ );
47
+
48
+ if (!indicatorComponentName) {
49
+ return new Response(
50
+ JSON.stringify({
51
+ error: `No indicator component found for '${archeTypeName}'`,
52
+ }),
53
+ {
54
+ status: 400,
55
+ headers: { "Content-Type": "application/json" },
56
+ }
57
+ );
58
+ }
59
+
60
+ const requiredComponentNames = archeTypeFields
61
+ .filter((field) => !field?.nullable)
62
+ .map((field) => field.componentName);
63
+
64
+ const allComponentNames = archeTypeFields.map(
65
+ (field) => field.componentName
66
+ );
67
+
68
+ const requiredComponentCount = requiredComponentNames.length;
69
+
70
+ let entityIdsResult: { entity_id: string }[];
71
+ let totalResult: { count: number }[];
72
+
73
+ const batchSize = limit * 3;
74
+ let currentOffset = offset;
75
+ const validEntities: ArcheTypeEntityRecord[] = [];
76
+ let hasMoreData = true;
77
+
78
+ while (validEntities.length < limit && hasMoreData) {
79
+ if (searchTerm) {
80
+ const searchPattern = `%${searchTerm}%`;
81
+ const componentNamePlaceholders = requiredComponentNames
82
+ .map((_, index) => `$${index + 2}`)
83
+ .join(", ");
84
+
85
+ entityIdsResult = await db.unsafe(
86
+ `SELECT entity_id FROM (
87
+ SELECT entity_id, MAX(created_at) as max_created_at
88
+ FROM components
89
+ WHERE TRUE ${deletedFilterBare}
90
+ GROUP BY entity_id
91
+ HAVING COUNT(DISTINCT CASE WHEN name IN (${componentNamePlaceholders}) THEN name END) = $${
92
+ requiredComponentNames.length + 2
93
+ }
94
+ ) archetype_entities
95
+ WHERE entity_id IN (
96
+ SELECT DISTINCT entity_id
97
+ FROM components
98
+ WHERE TRUE ${deletedFilterBare}
99
+ AND (
100
+ data::text ILIKE $1
101
+ OR id::text ILIKE $1
102
+ OR entity_id::text ILIKE $1
103
+ )
104
+ )
105
+ ORDER BY max_created_at DESC
106
+ LIMIT $${requiredComponentNames.length + 3} OFFSET $${
107
+ requiredComponentNames.length + 4
108
+ }`,
109
+ [
110
+ searchPattern,
111
+ ...requiredComponentNames,
112
+ requiredComponentCount,
113
+ batchSize,
114
+ currentOffset,
115
+ ]
116
+ );
117
+ } else {
118
+ entityIdsResult = await db.unsafe(
119
+ `SELECT entity_id FROM (
120
+ SELECT c.entity_id, MAX(c.created_at) as max_created_at
121
+ FROM components c
122
+ WHERE c.name = $1
123
+ ${deletedFilter}
124
+ GROUP BY c.entity_id
125
+ ORDER BY max_created_at DESC
126
+ LIMIT $2 OFFSET $3
127
+ ) sub`,
128
+ [indicatorComponentName, batchSize, currentOffset]
129
+ );
130
+ }
131
+
132
+ if (entityIdsResult.length === 0) {
133
+ hasMoreData = false;
134
+ break;
135
+ }
136
+
137
+ const entityIds = entityIdsResult.map((row) => row.entity_id);
138
+
139
+ const entityIdPlaceholders = entityIds
140
+ .map((_, index) => `$${index + 1}`)
141
+ .join(", ");
142
+ const componentNameStartIndex = entityIds.length + 1;
143
+ const componentNamePlaceholders = allComponentNames
144
+ .map((_, index) => `$${componentNameStartIndex + index}`)
145
+ .join(", ");
146
+
147
+ const componentsResult = await db.unsafe(
148
+ `SELECT c.entity_id, c.name, c.data
149
+ FROM components c
150
+ WHERE c.entity_id IN (${entityIdPlaceholders})
151
+ AND c.name IN (${componentNamePlaceholders})
152
+ ${deletedFilter}`,
153
+ [...entityIds, ...allComponentNames]
154
+ );
155
+
156
+ // When including deleted, also fetch entity-level deleted_at
157
+ let entityDeletedMap = new Map<string, string | null>();
158
+ if (includeDeleted) {
159
+ const entitiesResult = await db.unsafe(
160
+ `SELECT id, deleted_at FROM entities WHERE id IN (${entityIdPlaceholders})`,
161
+ entityIds
162
+ );
163
+ for (const row of entitiesResult) {
164
+ entityDeletedMap.set(
165
+ row.id as string,
166
+ (row.deleted_at as string) ?? null
167
+ );
168
+ }
169
+ }
170
+
171
+ const entityComponentsMap = new Map<string, Map<string, unknown>>();
172
+
173
+ for (const row of componentsResult) {
174
+ const entityId = row.entity_id as string;
175
+ const componentName = row.name as string;
176
+ const componentData = row.data as unknown;
177
+
178
+ if (!entityComponentsMap.has(entityId)) {
179
+ entityComponentsMap.set(entityId, new Map());
180
+ }
181
+ entityComponentsMap
182
+ .get(entityId)!
183
+ .set(componentName, componentData);
184
+ }
185
+
186
+ for (const entityId of entityIds) {
187
+ const componentsMap = entityComponentsMap.get(entityId);
188
+
189
+ if (!componentsMap) {
190
+ continue;
191
+ }
192
+
193
+ const allRequiredComponentsPresent =
194
+ requiredComponentNames.every((name) =>
195
+ componentsMap.has(name)
196
+ );
197
+
198
+ if (allRequiredComponentsPresent) {
199
+ const componentsObject: Record<string, unknown> = {};
200
+ for (const [name, data] of componentsMap) {
201
+ componentsObject[name] = data;
202
+ }
203
+
204
+ const record: ArcheTypeEntityRecord = {
205
+ entityId,
206
+ components: componentsObject,
207
+ };
208
+
209
+ if (includeDeleted) {
210
+ record.deleted_at = entityDeletedMap.get(entityId) ?? null;
211
+ }
212
+
213
+ validEntities.push(record);
214
+
215
+ if (validEntities.length >= limit) {
216
+ break;
217
+ }
218
+ }
219
+ }
220
+
221
+ currentOffset += batchSize;
222
+
223
+ if (entityIdsResult.length < batchSize) {
224
+ hasMoreData = false;
225
+ }
226
+ }
227
+
228
+ if (searchTerm) {
229
+ const searchPattern = `%${searchTerm}%`;
230
+ const componentNamePlaceholders = requiredComponentNames
231
+ .map((_, index) => `$${index + 2}`)
232
+ .join(", ");
233
+
234
+ totalResult = await db.unsafe(
235
+ `SELECT COUNT(DISTINCT c.entity_id) as count
236
+ FROM components c
237
+ WHERE TRUE ${deletedFilter}
238
+ AND (
239
+ c.data::text ILIKE $1
240
+ OR c.id::text ILIKE $1
241
+ OR c.entity_id::text ILIKE $1
242
+ )
243
+ AND c.entity_id IN (
244
+ SELECT entity_id
245
+ FROM components
246
+ WHERE TRUE ${deletedFilterBare}
247
+ GROUP BY entity_id
248
+ HAVING COUNT(DISTINCT CASE WHEN name IN (${componentNamePlaceholders}) THEN name END) = $${
249
+ requiredComponentNames.length + 2
250
+ }
251
+ )`,
252
+ [
253
+ searchPattern,
254
+ ...requiredComponentNames,
255
+ requiredComponentCount,
256
+ ]
257
+ );
258
+ } else {
259
+ totalResult = await db.unsafe(
260
+ `SELECT COUNT(DISTINCT c.entity_id) as count
261
+ FROM components c
262
+ WHERE c.name = $1
263
+ ${deletedFilter}`,
264
+ [indicatorComponentName]
265
+ );
266
+ }
267
+
268
+ const total = Number(totalResult[0]?.count ?? 0);
269
+
270
+ const responseData: StudioArcheTypeResponse = {
271
+ name: archeTypeName,
272
+ fields: archeTypeFields,
273
+ indicatorComponent: indicatorComponentName,
274
+ entities: validEntities,
275
+ total,
276
+ limit,
277
+ offset,
278
+ };
279
+
280
+ return new Response(JSON.stringify(responseData), {
281
+ headers: { "Content-Type": "application/json" },
282
+ });
283
+ } catch (error) {
284
+ const errorMessage =
285
+ error instanceof Error ? error.message : "Unknown error";
286
+ return new Response(
287
+ JSON.stringify({
288
+ error: `Failed to fetch archetype data: ${errorMessage}`,
289
+ }),
290
+ {
291
+ status: 500,
292
+ headers: { "Content-Type": "application/json" },
293
+ }
294
+ );
295
+ }
296
+ }
297
+
298
+ export async function handleStudioArcheTypeDeleteRequest(
299
+ archeTypeName: string,
300
+ requestBody: DeleteArcheTypeEntitiesRequest
301
+ ): Promise<Response> {
302
+ const { entityIds } = requestBody;
303
+
304
+ if (!entityIds || !Array.isArray(entityIds) || entityIds.length === 0) {
305
+ return new Response(
306
+ JSON.stringify({
307
+ error: "entityIds array is required and must not be empty",
308
+ }),
309
+ {
310
+ status: 400,
311
+ headers: { "Content-Type": "application/json" },
312
+ }
313
+ );
314
+ }
315
+
316
+ try {
317
+ const idPlaceholders = entityIds
318
+ .map((_, index) => `$${index + 1}`)
319
+ .join(", ");
320
+
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
329
+ await db.unsafe(
330
+ `DELETE FROM components WHERE entity_id IN (${idPlaceholders})`,
331
+ entityIds
332
+ );
333
+
334
+ // 3. Delete from entities
335
+ await db.unsafe(
336
+ `DELETE FROM entities WHERE id IN (${idPlaceholders})`,
337
+ entityIds
338
+ );
339
+
340
+ const responseData: DeleteResponse = {
341
+ success: true,
342
+ deletedCount: entityIds.length,
343
+ message: `Successfully deleted ${entityIds.length} entity(ies) of type ${archeTypeName}`,
344
+ };
345
+
346
+ return new Response(JSON.stringify(responseData), {
347
+ headers: { "Content-Type": "application/json" },
348
+ });
349
+ } catch (error) {
350
+ const errorMessage =
351
+ error instanceof Error ? error.message : "Unknown error";
352
+ return new Response(
353
+ JSON.stringify({
354
+ error: `Failed to delete entities: ${errorMessage}`,
355
+ }),
356
+ {
357
+ status: 500,
358
+ headers: { "Content-Type": "application/json" },
359
+ }
360
+ );
361
+ }
362
+ }
@@ -0,0 +1,58 @@
1
+ import db from "../database";
2
+ import { GenerateTableName } from "../database/DatabaseHelper";
3
+ import type { ComponentTypeInfo, StudioComponentsResponse } from "./types";
4
+
5
+ export async function handleStudioComponentsRequest(): Promise<Response> {
6
+ try {
7
+ // Get distinct component names with entity counts
8
+ const componentRows = await db`
9
+ SELECT name, COUNT(DISTINCT entity_id) as entity_count
10
+ FROM components
11
+ WHERE deleted_at IS NULL
12
+ GROUP BY name
13
+ ORDER BY entity_count DESC
14
+ `;
15
+
16
+ const components: ComponentTypeInfo[] = [];
17
+
18
+ for (const row of componentRows) {
19
+ const name = row.name as string;
20
+ const entityCount = Number(row.entity_count);
21
+ const partitionTable = GenerateTableName(name);
22
+
23
+ // Get sample row to discover JSONB field shape
24
+ const sampleResult = await db.unsafe(
25
+ `SELECT data FROM components WHERE name = $1 AND deleted_at IS NULL AND data IS NOT NULL LIMIT 1`,
26
+ [name]
27
+ );
28
+
29
+ let fields: string[] = [];
30
+ if (sampleResult.length > 0 && sampleResult[0].data) {
31
+ const sampleData = sampleResult[0].data;
32
+ if (typeof sampleData === "object" && sampleData !== null) {
33
+ fields = Object.keys(sampleData as Record<string, unknown>);
34
+ }
35
+ }
36
+
37
+ components.push({ name, entityCount, partitionTable, fields });
38
+ }
39
+
40
+ const responseData: StudioComponentsResponse = { components };
41
+
42
+ return new Response(JSON.stringify(responseData), {
43
+ headers: { "Content-Type": "application/json" },
44
+ });
45
+ } catch (error) {
46
+ const errorMessage =
47
+ error instanceof Error ? error.message : "Unknown error";
48
+ return new Response(
49
+ JSON.stringify({
50
+ error: `Failed to fetch components: ${errorMessage}`,
51
+ }),
52
+ {
53
+ status: 500,
54
+ headers: { "Content-Type": "application/json" },
55
+ }
56
+ );
57
+ }
58
+ }
@@ -0,0 +1,80 @@
1
+ import db from "../database";
2
+ import type { EntityInspectorResponse } from "./types";
3
+
4
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+
6
+ export async function handleEntityInspectorRequest(
7
+ entityId: string
8
+ ): Promise<Response> {
9
+ if (!entityId || !UUID_REGEX.test(entityId)) {
10
+ return new Response(
11
+ JSON.stringify({ error: "Invalid entity ID format. Expected a UUID." }),
12
+ {
13
+ status: 400,
14
+ headers: { "Content-Type": "application/json" },
15
+ }
16
+ );
17
+ }
18
+
19
+ try {
20
+ const entityResult = await db`
21
+ SELECT id, created_at, updated_at, deleted_at
22
+ FROM entities
23
+ WHERE id = ${entityId}
24
+ `;
25
+
26
+ if (entityResult.length === 0) {
27
+ return new Response(
28
+ JSON.stringify({ error: `Entity '${entityId}' not found` }),
29
+ {
30
+ status: 404,
31
+ headers: { "Content-Type": "application/json" },
32
+ }
33
+ );
34
+ }
35
+
36
+ const entity = entityResult[0];
37
+
38
+ // Fetch ALL components for this entity (including soft-deleted)
39
+ const componentsResult = await db`
40
+ SELECT id, name, type_id, data, created_at, updated_at, deleted_at
41
+ FROM components
42
+ WHERE entity_id = ${entityId}
43
+ ORDER BY name ASC, created_at ASC
44
+ `;
45
+
46
+ const responseData: EntityInspectorResponse = {
47
+ entity: {
48
+ id: entity.id as string,
49
+ created_at: entity.created_at as string,
50
+ updated_at: entity.updated_at as string,
51
+ deleted_at: (entity.deleted_at as string) ?? null,
52
+ },
53
+ components: componentsResult.map((row: Record<string, unknown>) => ({
54
+ id: row.id as string,
55
+ name: row.name as string,
56
+ type_id: row.type_id as string,
57
+ data: row.data as unknown,
58
+ created_at: row.created_at as string,
59
+ updated_at: row.updated_at as string,
60
+ deleted_at: (row.deleted_at as string) ?? null,
61
+ })),
62
+ };
63
+
64
+ return new Response(JSON.stringify(responseData), {
65
+ headers: { "Content-Type": "application/json" },
66
+ });
67
+ } catch (error) {
68
+ const errorMessage =
69
+ error instanceof Error ? error.message : "Unknown error";
70
+ return new Response(
71
+ JSON.stringify({
72
+ error: `Failed to fetch entity: ${errorMessage}`,
73
+ }),
74
+ {
75
+ status: 500,
76
+ headers: { "Content-Type": "application/json" },
77
+ }
78
+ );
79
+ }
80
+ }
@@ -0,0 +1,27 @@
1
+ import {
2
+ handleStudioTableRequest,
3
+ handleStudioTableDeleteRequest,
4
+ handleGetTables,
5
+ } from "./tables";
6
+ import {
7
+ handleStudioArcheTypeRecordsRequest,
8
+ handleStudioArcheTypeDeleteRequest,
9
+ } from "./archetypes";
10
+ import { handleEntityInspectorRequest } from "./entity";
11
+ import { handleStudioStatsRequest } from "./stats";
12
+ import { handleStudioComponentsRequest } from "./components";
13
+ import { handleStudioQueryRequest } from "./query";
14
+
15
+ const studioEndpoint = {
16
+ handleStudioTableRequest,
17
+ handleStudioArcheTypeRecordsRequest,
18
+ handleStudioTableDeleteRequest,
19
+ handleStudioArcheTypeDeleteRequest,
20
+ handleEntityInspectorRequest,
21
+ handleStudioStatsRequest,
22
+ handleStudioComponentsRequest,
23
+ handleStudioQueryRequest,
24
+ getTables: handleGetTables,
25
+ };
26
+
27
+ export default studioEndpoint;
@@ -0,0 +1,93 @@
1
+ import db from "../database";
2
+ import type { StudioQueryRequest, StudioQueryResponse } from "./types";
3
+
4
+ const FORBIDDEN_KEYWORDS = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|COPY|EXECUTE|DO)\b/i;
5
+ const MAX_ROWS = 500;
6
+ const QUERY_TIMEOUT_MS = 10_000;
7
+
8
+ export async function handleStudioQueryRequest(
9
+ requestBody: StudioQueryRequest
10
+ ): Promise<Response> {
11
+ // Only allow in non-production
12
+ if (process.env.NODE_ENV === "production") {
13
+ return new Response(
14
+ JSON.stringify({ error: "Query runner is disabled in production" }),
15
+ {
16
+ status: 403,
17
+ headers: { "Content-Type": "application/json" },
18
+ }
19
+ );
20
+ }
21
+
22
+ const { sql } = requestBody;
23
+
24
+ if (!sql || typeof sql !== "string" || sql.trim().length === 0) {
25
+ return new Response(
26
+ JSON.stringify({ error: "SQL query is required" }),
27
+ {
28
+ status: 400,
29
+ headers: { "Content-Type": "application/json" },
30
+ }
31
+ );
32
+ }
33
+
34
+ const trimmed = sql.trim();
35
+
36
+ // Block write operations
37
+ if (FORBIDDEN_KEYWORDS.test(trimmed)) {
38
+ return new Response(
39
+ JSON.stringify({
40
+ error: "Only read-only (SELECT) queries are allowed",
41
+ }),
42
+ {
43
+ status: 400,
44
+ headers: { "Content-Type": "application/json" },
45
+ }
46
+ );
47
+ }
48
+
49
+ // Enforce LIMIT if not present
50
+ const hasLimit = /\bLIMIT\b/i.test(trimmed);
51
+ const queryToRun = hasLimit ? trimmed : `${trimmed} LIMIT ${MAX_ROWS}`;
52
+
53
+ try {
54
+ const startTime = Date.now();
55
+
56
+ const result = await Promise.race([
57
+ db.unsafe(queryToRun),
58
+ new Promise<never>((_, reject) =>
59
+ setTimeout(
60
+ () => reject(new Error("Query timed out")),
61
+ QUERY_TIMEOUT_MS
62
+ )
63
+ ),
64
+ ]);
65
+
66
+ const duration = Date.now() - startTime;
67
+
68
+ const rows = Array.isArray(result) ? result : [];
69
+ const columns =
70
+ rows.length > 0 ? Object.keys(rows[0] as Record<string, unknown>) : [];
71
+
72
+ const responseData: StudioQueryResponse = {
73
+ columns,
74
+ rows: rows.slice(0, MAX_ROWS) as Record<string, unknown>[],
75
+ rowCount: rows.length,
76
+ duration,
77
+ };
78
+
79
+ return new Response(JSON.stringify(responseData), {
80
+ headers: { "Content-Type": "application/json" },
81
+ });
82
+ } catch (error) {
83
+ const errorMessage =
84
+ error instanceof Error ? error.message : "Unknown error";
85
+ return new Response(
86
+ JSON.stringify({ error: errorMessage }),
87
+ {
88
+ status: 400,
89
+ headers: { "Content-Type": "application/json" },
90
+ }
91
+ );
92
+ }
93
+ }
@@ -0,0 +1,76 @@
1
+ import db from "../database";
2
+ import { getSerializedMetadataStorage } from "../core/metadata";
3
+ import type { StudioStatsResponse, ComponentTypeStats, ArcheTypeStats } from "./types";
4
+
5
+ export async function handleStudioStatsRequest(): Promise<Response> {
6
+ try {
7
+ // Run entity counts and component type counts in parallel
8
+ const [activeCountResult, deletedCountResult, componentTypesResult] =
9
+ await Promise.all([
10
+ db`SELECT COUNT(*) as count FROM entities WHERE deleted_at IS NULL`,
11
+ db`SELECT COUNT(*) as count FROM entities WHERE deleted_at IS NOT NULL`,
12
+ db`SELECT name, COUNT(*) as count FROM components WHERE deleted_at IS NULL GROUP BY name ORDER BY count DESC`,
13
+ ]);
14
+
15
+ const activeCount = Number(activeCountResult[0]?.count ?? 0);
16
+ const deletedCount = Number(deletedCountResult[0]?.count ?? 0);
17
+
18
+ const componentTypes: ComponentTypeStats[] = componentTypesResult.map(
19
+ (row: Record<string, unknown>) => ({
20
+ name: row.name as string,
21
+ count: Number(row.count),
22
+ })
23
+ );
24
+
25
+ // Derive archetype stats from metadata + component counts
26
+ const metadata = getSerializedMetadataStorage();
27
+ const componentCountMap = new Map(
28
+ componentTypes.map((ct) => [ct.name, ct.count])
29
+ );
30
+
31
+ const archetypes: ArcheTypeStats[] = [];
32
+ for (const [name, fields] of Object.entries(metadata.archeTypes)) {
33
+ const requiredComponents = fields.filter((f) => !f.nullable);
34
+ const indicatorComponent =
35
+ requiredComponents.find((f) =>
36
+ f.componentName.endsWith("Tag")
37
+ ) ?? requiredComponents[0];
38
+
39
+ archetypes.push({
40
+ name,
41
+ entityCount: indicatorComponent
42
+ ? componentCountMap.get(indicatorComponent.componentName) ?? 0
43
+ : 0,
44
+ componentCount: fields.length,
45
+ });
46
+ }
47
+
48
+ archetypes.sort((a, b) => b.entityCount - a.entityCount);
49
+
50
+ const responseData: StudioStatsResponse = {
51
+ entities: {
52
+ active: activeCount,
53
+ deleted: deletedCount,
54
+ total: activeCount + deletedCount,
55
+ },
56
+ componentTypes,
57
+ archetypes,
58
+ };
59
+
60
+ return new Response(JSON.stringify(responseData), {
61
+ headers: { "Content-Type": "application/json" },
62
+ });
63
+ } catch (error) {
64
+ const errorMessage =
65
+ error instanceof Error ? error.message : "Unknown error";
66
+ return new Response(
67
+ JSON.stringify({
68
+ error: `Failed to fetch stats: ${errorMessage}`,
69
+ }),
70
+ {
71
+ status: 500,
72
+ headers: { "Content-Type": "application/json" },
73
+ }
74
+ );
75
+ }
76
+ }