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,342 @@
1
+ import db from "./index";
2
+ import { logger } from "../core/Logger";
3
+
4
+ const validateIdentifier = (str: string, maxLength: number = 64): string => {
5
+ if (!str || typeof str !== 'string' || str.length === 0 || str.length > maxLength) {
6
+ throw new Error(`Invalid identifier: ${str}`);
7
+ }
8
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(str)) {
9
+ throw new Error(`Invalid identifier format: ${str}`);
10
+ }
11
+ return str;
12
+ };
13
+
14
+ export type IndexType = 'gin' | 'btree' | 'hash' | 'numeric';
15
+
16
+ export interface IndexDefinition {
17
+ tableName: string;
18
+ field: string;
19
+ indexType: IndexType;
20
+ isDateField?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Ensures a JSONB path-specific index exists on a table
25
+ * @param tableName The table name to create index on
26
+ * @param field The JSONB field path to index
27
+ * @param indexType The type of index to create
28
+ * @param isDateField Whether this field should be cast to DATE for BTREE indexing
29
+ */
30
+ export const ensureJSONBPathIndex = async (
31
+ tableName: string,
32
+ field: string,
33
+ indexType: IndexType = 'gin',
34
+ isDateField: boolean = false
35
+ ): Promise<void> => {
36
+ tableName = validateIdentifier(tableName);
37
+ field = validateIdentifier(field);
38
+
39
+ const indexName = `idx_${tableName}_${field}_${indexType}${isDateField ? '_date' : ''}`;
40
+
41
+ try {
42
+
43
+ logger.trace(`Ensuring ${indexType.toUpperCase()} index ${indexName} on ${tableName} for field ${field}${isDateField ? ' (date field - indexed as text)' : ''}`);
44
+
45
+ // Check if index already exists
46
+ const existingIndexes = await db.unsafe(`
47
+ SELECT indexname
48
+ FROM pg_indexes
49
+ WHERE tablename = '${tableName}' AND indexname = '${indexName}'
50
+ `);
51
+
52
+ if (existingIndexes.length > 0) {
53
+ logger.trace(`Index ${indexName} already exists`);
54
+ return;
55
+ }
56
+
57
+ // Check if table is partitioned
58
+ const partitionCheck = await db.unsafe(`
59
+ SELECT relkind
60
+ FROM pg_class
61
+ WHERE relname = '${tableName}' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
62
+ `);
63
+
64
+ const isPartitioned = partitionCheck.length > 0 && partitionCheck[0].relkind === 'p';
65
+ const useConcurrently = !isPartitioned && !process.env.USE_PGLITE;
66
+
67
+ let indexSQL: string;
68
+
69
+ switch (indexType) {
70
+ case 'gin':
71
+ // GIN indexes always use CONCURRENTLY for non-blocking operation (if not partitioned)
72
+ indexSQL = `CREATE INDEX${useConcurrently ? ' CONCURRENTLY' : ''} ${indexName} ON ${tableName} USING GIN ((data->'${field}') jsonb_path_ops)`;
73
+ break;
74
+
75
+ case 'btree':
76
+ if (isDateField) {
77
+ // BTREE index on date field - store as text and let PostgreSQL handle conversions at query time
78
+ // Note: Direct casting in index expressions requires IMMUTABLE functions
79
+ // Storing as text allows the index to work while queries can still cast when filtering
80
+ indexSQL = `CREATE INDEX${useConcurrently ? ' CONCURRENTLY' : ''} ${indexName} ON ${tableName} ((data->>'${field}'))`;
81
+ } else {
82
+ // BTREE index on text field
83
+ indexSQL = `CREATE INDEX${useConcurrently ? ' CONCURRENTLY' : ''} ${indexName} ON ${tableName} ((data->>'${field}'))`;
84
+ }
85
+ break;
86
+
87
+ case 'hash':
88
+ // HASH index (generally not recommended for JSONB fields)
89
+ indexSQL = `CREATE INDEX${useConcurrently ? ' CONCURRENTLY' : ''} ${indexName} ON ${tableName} USING HASH ((data->>'${field}'))`;
90
+ break;
91
+
92
+ default:
93
+ throw new Error(`Unsupported index type: ${indexType}`);
94
+ }
95
+
96
+ logger.trace(`Creating index with SQL: ${indexSQL}`);
97
+ await db.unsafe(indexSQL);
98
+ logger.info(`Created ${indexType.toUpperCase()} index ${indexName} on ${tableName}${useConcurrently ? ' (concurrently)' : ' (blocking)'}`);
99
+
100
+ } catch (error: any) {
101
+ // Check if the error is about duplicate key or relation already exists (race condition handling)
102
+ if (error.message && (
103
+ error.message.includes('duplicate key value violates unique constraint "pg_class_relname_nsp_index"') ||
104
+ error.message.includes('already exists') ||
105
+ error.code === '42P07' // PostgreSQL error code for duplicate_table/relation
106
+ )) {
107
+ logger.trace(`Index ${indexName} already exists (confirmed by error), skipping creation`);
108
+ return;
109
+ }
110
+ // Handle deadlock by checking if index was created by another process
111
+ if (error.code === '40P01' || (error.message && error.message.includes('deadlock'))) {
112
+ logger.warn(`Deadlock detected while creating index ${indexName}, checking if it exists now...`);
113
+ // Wait a bit and check if index exists now (created by another process)
114
+ await new Promise(resolve => setTimeout(resolve, 500));
115
+ const checkAgain = await db.unsafe(`
116
+ SELECT indexname FROM pg_indexes
117
+ WHERE tablename = '${tableName}' AND indexname = '${indexName}'
118
+ `);
119
+ if (checkAgain.length > 0) {
120
+ logger.trace(`Index ${indexName} was created by another process during deadlock`);
121
+ return;
122
+ }
123
+ // If still doesn't exist, log but don't throw - index creation is best-effort
124
+ logger.warn(`Index ${indexName} still doesn't exist after deadlock, skipping`);
125
+ return;
126
+ }
127
+ logger.error(`Failed to create ${indexType} index on ${tableName} for field ${field}: ${error}`);
128
+ throw error;
129
+ }
130
+ };
131
+
132
+ /**
133
+ * Ensures multiple JSONB path indexes exist on a table
134
+ * @param tableName The table name to create indexes on
135
+ * @param indexDefinitions Array of index definitions to create
136
+ */
137
+ export const ensureMultipleJSONBPathIndexes = async (
138
+ tableName: string,
139
+ indexDefinitions: IndexDefinition[]
140
+ ): Promise<void> => {
141
+ for (const def of indexDefinitions) {
142
+ if (def.indexType === 'numeric') {
143
+ // Use numeric index for range queries
144
+ await ensureNumericIndex(def.tableName, def.field);
145
+ } else {
146
+ await ensureJSONBPathIndex(
147
+ def.tableName,
148
+ def.field,
149
+ def.indexType,
150
+ def.isDateField
151
+ );
152
+ }
153
+ }
154
+ };
155
+
156
+ /**
157
+ * Analyzes a table to update query planner statistics
158
+ * @param tableName The table name to analyze
159
+ */
160
+ export const analyzeTable = async (tableName: string): Promise<void> => {
161
+ try {
162
+ tableName = validateIdentifier(tableName);
163
+ logger.trace(`Running ANALYZE on table ${tableName}`);
164
+ await db.unsafe(`ANALYZE ${tableName}`);
165
+ logger.info(`Completed ANALYZE on table ${tableName}`);
166
+ } catch (error) {
167
+ logger.error(`Failed to ANALYZE table ${tableName}: ${error}`);
168
+ throw error;
169
+ }
170
+ };
171
+
172
+ /**
173
+ * Analyzes all component partition tables
174
+ */
175
+ /**
176
+ * Creates a functional index on a JSONB numeric field for efficient range queries.
177
+ * This is critical for queries like `WHERE (data->>'age')::numeric BETWEEN 25 AND 35`
178
+ *
179
+ * @param tableName The table name to create index on
180
+ * @param field The JSONB field path containing numeric values
181
+ */
182
+ export const ensureNumericIndex = async (
183
+ tableName: string,
184
+ field: string
185
+ ): Promise<void> => {
186
+ tableName = validateIdentifier(tableName);
187
+ field = validateIdentifier(field);
188
+
189
+ const indexName = `idx_${tableName}_${field}_numeric`;
190
+
191
+ try {
192
+ logger.trace(`Ensuring numeric index ${indexName} on ${tableName} for field ${field}`);
193
+
194
+ // Check if index already exists
195
+ const existingIndexes = await db.unsafe(`
196
+ SELECT indexname
197
+ FROM pg_indexes
198
+ WHERE tablename = '${tableName}' AND indexname = '${indexName}'
199
+ `);
200
+
201
+ if (existingIndexes.length > 0) {
202
+ logger.trace(`Index ${indexName} already exists`);
203
+ return;
204
+ }
205
+
206
+ // Check if table is partitioned
207
+ const partitionCheck = await db.unsafe(`
208
+ SELECT relkind
209
+ FROM pg_class
210
+ WHERE relname = '${tableName}' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
211
+ `);
212
+
213
+ const isPartitioned = partitionCheck.length > 0 && partitionCheck[0].relkind === 'p';
214
+ const useConcurrently = !isPartitioned && !process.env.USE_PGLITE;
215
+
216
+ // Create a partial index that only includes rows where the field is a valid number
217
+ // This prevents errors when some rows have non-numeric values
218
+ const indexSQL = `CREATE INDEX${useConcurrently ? ' CONCURRENTLY' : ''} ${indexName}
219
+ ON ${tableName} (((data->>'${field}')::numeric))
220
+ WHERE data->>'${field}' IS NOT NULL
221
+ AND data->>'${field}' ~ '^-?[0-9]+\\.?[0-9]*$'`;
222
+
223
+ logger.trace(`Creating numeric index with SQL: ${indexSQL}`);
224
+ await db.unsafe(indexSQL);
225
+ logger.info(`Created numeric index ${indexName} on ${tableName}${useConcurrently ? ' (concurrently)' : ' (blocking)'}`);
226
+
227
+ } catch (error: any) {
228
+ if (error.message && (
229
+ error.message.includes('already exists') ||
230
+ error.code === '42P07'
231
+ )) {
232
+ logger.trace(`Index ${indexName} already exists (confirmed by error), skipping creation`);
233
+ return;
234
+ }
235
+ if (error.code === '40P01' || (error.message && error.message.includes('deadlock'))) {
236
+ logger.warn(`Deadlock detected while creating index ${indexName}, skipping`);
237
+ return;
238
+ }
239
+ logger.error(`Failed to create numeric index on ${tableName} for field ${field}: ${error}`);
240
+ throw error;
241
+ }
242
+ };
243
+
244
+ /**
245
+ * Creates a composite index on multiple JSONB fields for efficient combined filter queries.
246
+ * Useful for queries like `WHERE status = 'active' AND age >= 21`
247
+ *
248
+ * @param tableName The table name to create index on
249
+ * @param fields Array of field definitions with type information
250
+ */
251
+ export const ensureCompositeIndex = async (
252
+ tableName: string,
253
+ fields: Array<{ name: string; type: 'text' | 'numeric' | 'boolean' }>
254
+ ): Promise<void> => {
255
+ tableName = validateIdentifier(tableName);
256
+ fields.forEach(f => validateIdentifier(f.name));
257
+
258
+ const fieldNames = fields.map(f => f.name).join('_');
259
+ const indexName = `idx_${tableName}_${fieldNames}_composite`;
260
+
261
+ try {
262
+ logger.trace(`Ensuring composite index ${indexName} on ${tableName}`);
263
+
264
+ // Check if index already exists
265
+ const existingIndexes = await db.unsafe(`
266
+ SELECT indexname
267
+ FROM pg_indexes
268
+ WHERE tablename = '${tableName}' AND indexname = '${indexName}'
269
+ `);
270
+
271
+ if (existingIndexes.length > 0) {
272
+ logger.trace(`Index ${indexName} already exists`);
273
+ return;
274
+ }
275
+
276
+ // Check if table is partitioned
277
+ const partitionCheck = await db.unsafe(`
278
+ SELECT relkind
279
+ FROM pg_class
280
+ WHERE relname = '${tableName}' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
281
+ `);
282
+
283
+ const isPartitioned = partitionCheck.length > 0 && partitionCheck[0].relkind === 'p';
284
+ const useConcurrently = !isPartitioned && !process.env.USE_PGLITE;
285
+
286
+ // Build index expressions for each field
287
+ const indexExpressions = fields.map(f => {
288
+ switch (f.type) {
289
+ case 'numeric':
290
+ return `((data->>'${f.name}')::numeric)`;
291
+ case 'boolean':
292
+ return `((data->>'${f.name}')::boolean)`;
293
+ default:
294
+ return `(data->>'${f.name}')`;
295
+ }
296
+ }).join(', ');
297
+
298
+ const indexSQL = `CREATE INDEX${useConcurrently ? ' CONCURRENTLY' : ''} ${indexName}
299
+ ON ${tableName} (${indexExpressions})`;
300
+
301
+ logger.trace(`Creating composite index with SQL: ${indexSQL}`);
302
+ await db.unsafe(indexSQL);
303
+ logger.info(`Created composite index ${indexName} on ${tableName}${useConcurrently ? ' (concurrently)' : ' (blocking)'}`);
304
+
305
+ } catch (error: any) {
306
+ if (error.message && (
307
+ error.message.includes('already exists') ||
308
+ error.code === '42P07'
309
+ )) {
310
+ logger.trace(`Index ${indexName} already exists (confirmed by error), skipping creation`);
311
+ return;
312
+ }
313
+ if (error.code === '40P01' || (error.message && error.message.includes('deadlock'))) {
314
+ logger.warn(`Deadlock detected while creating index ${indexName}, skipping`);
315
+ return;
316
+ }
317
+ logger.error(`Failed to create composite index on ${tableName}: ${error}`);
318
+ throw error;
319
+ }
320
+ };
321
+
322
+ export const analyzeAllComponentTables = async (): Promise<void> => {
323
+ try {
324
+ logger.trace(`Analyzing all component tables`);
325
+
326
+ // Get all component partition tables
327
+ const tables = await db.unsafe(`
328
+ SELECT tablename
329
+ FROM pg_tables
330
+ WHERE tablename LIKE 'components_%' AND schemaname = 'public'
331
+ `);
332
+
333
+ for (const row of tables) {
334
+ await analyzeTable(row.tablename);
335
+ }
336
+
337
+ logger.info(`Completed ANALYZE on ${tables.length} component tables`);
338
+ } catch (error) {
339
+ logger.error(`Failed to analyze component tables: ${error}`);
340
+ throw error;
341
+ }
342
+ };
@@ -0,0 +1,226 @@
1
+ import { logger } from "../core/Logger";
2
+
3
+ export interface CacheEntry {
4
+ sql: string;
5
+ preparedStatement: any; // Bun's SQL prepared statement type
6
+ lastUsed: number;
7
+ hitCount: number;
8
+ createdAt: number;
9
+ }
10
+
11
+ export interface CacheStats {
12
+ size: number;
13
+ maxSize: number;
14
+ hits: number;
15
+ misses: number;
16
+ evictions: number;
17
+ totalPlanningTimeSaved: number;
18
+ averagePlanningTimeSaved: number;
19
+ }
20
+
21
+ /**
22
+ * LRU Cache for prepared statements to eliminate PostgreSQL planning overhead
23
+ * for repeated query patterns in the Bunsane Query system.
24
+ */
25
+ export class PreparedStatementCache {
26
+ private cache: Map<string, CacheEntry> = new Map();
27
+ private maxSize: number;
28
+ private stats = {
29
+ hits: 0,
30
+ misses: 0,
31
+ evictions: 0,
32
+ totalPlanningTimeSaved: 0
33
+ };
34
+
35
+ constructor(maxSize: number = 100) {
36
+ this.maxSize = maxSize;
37
+ logger.info(`Initialized PreparedStatementCache with max size: ${maxSize}`);
38
+ }
39
+
40
+ /**
41
+ * Generate a cache key from QueryContext fingerprint
42
+ */
43
+ public generateCacheKey(context: {
44
+ componentIds: Set<string>;
45
+ componentFilters: Map<string, any[]>;
46
+ sortOrders: any[];
47
+ excludedComponentIds: Set<string>;
48
+ hasCTE: boolean;
49
+ cteName: string;
50
+ }): string {
51
+ // Create a deterministic fingerprint of the query structure
52
+ const components = Array.from(context.componentIds).sort().join(',');
53
+ const excludedComponents = Array.from(context.excludedComponentIds).sort().join(',');
54
+ const filters = Array.from(context.componentFilters.entries())
55
+ .map(([typeId, filters]) => `${typeId}:${filters.map(f => `${f.field}${f.operator}`).sort().join('|')}`)
56
+ .sort()
57
+ .join(';');
58
+ const sorts = context.sortOrders
59
+ .map(s => `${s.component}.${s.property}:${s.direction}`)
60
+ .sort()
61
+ .join(',');
62
+
63
+ const key = `${components}|${excludedComponents}|${filters}|${sorts}|${context.hasCTE}|${context.cteName}`;
64
+ return key;
65
+ }
66
+
67
+ /**
68
+ * Get a prepared statement from cache, or create new one
69
+ */
70
+ public async getOrCreate(sql: string, key: string, db: any): Promise<{ statement: any; isHit: boolean }> {
71
+ const now = Date.now();
72
+ const existing = this.cache.get(key);
73
+
74
+ if (existing) {
75
+ // Cache hit
76
+ existing.lastUsed = now;
77
+ existing.hitCount++;
78
+ this.stats.hits++;
79
+ // logger.trace(`Cache hit for key: ${key.substring(0, 50)}...`);
80
+ return { statement: existing.preparedStatement, isHit: true };
81
+ }
82
+
83
+ // Cache miss - create new prepared statement
84
+ this.stats.misses++;
85
+ logger.trace(`Cache miss for key: ${key.substring(0, 50)}..., creating prepared statement`);
86
+
87
+ // Create prepared statement using Bun's SQL
88
+ // Note: Bun's SQL may not have explicit prepare(), so we'll use the query as-is
89
+ // In postgres.js this would be: db.unsafe(sql, params, { prepare: true })
90
+ // For Bun, we may need to adapt this
91
+ const preparedStatement = { sql, _isPrepared: true }; // Placeholder
92
+
93
+ const entry: CacheEntry = {
94
+ sql,
95
+ preparedStatement,
96
+ lastUsed: now,
97
+ hitCount: 1,
98
+ createdAt: now
99
+ };
100
+
101
+ // Evict if at capacity
102
+ if (this.cache.size >= this.maxSize) {
103
+ this.evictLRU();
104
+ }
105
+
106
+ this.cache.set(key, entry);
107
+ return { statement: preparedStatement, isHit: false };
108
+ }
109
+
110
+ /**
111
+ * Execute a prepared statement with parameters
112
+ */
113
+ public async execute(statement: any, params: any[], db: any): Promise<any[]> {
114
+ // Validate params to catch empty strings that would cause UUID parsing errors
115
+ for (let i = 0; i < params.length; i++) {
116
+ const param = params[i];
117
+ if (param === '' || (typeof param === 'string' && param.trim() === '')) {
118
+ logger.error(`[PreparedStatementCache] Empty string parameter at position ${i + 1}`);
119
+ logger.error(`[PreparedStatementCache] SQL: ${statement.sql}`);
120
+ logger.error(`[PreparedStatementCache] All params: ${JSON.stringify(params)}`);
121
+ throw new Error(`PreparedStatementCache.execute: Parameter $${i + 1} is an empty string. SQL: ${statement.sql.substring(0, 100)}...`);
122
+ }
123
+ }
124
+
125
+ // For Bun's SQL, we still use db.unsafe() but with the prepared statement concept
126
+ // In a real implementation, this might use a prepared statement pool
127
+ return await db.unsafe(statement.sql, params);
128
+ }
129
+
130
+ /**
131
+ * Evict least recently used entry
132
+ */
133
+ private evictLRU(): void {
134
+ let oldestKey: string | null = null;
135
+ let oldestTime = Date.now();
136
+
137
+ for (const [key, entry] of this.cache.entries()) {
138
+ if (entry.lastUsed < oldestTime) {
139
+ oldestTime = entry.lastUsed;
140
+ oldestKey = key;
141
+ }
142
+ }
143
+
144
+ if (oldestKey) {
145
+ this.cache.delete(oldestKey);
146
+ this.stats.evictions++;
147
+ logger.trace(`Evicted LRU cache entry: ${oldestKey.substring(0, 50)}...`);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Invalidate cache entries when component schemas change
153
+ */
154
+ public invalidateByComponent(componentTypeId: string): void {
155
+ const keysToDelete: string[] = [];
156
+
157
+ for (const [key, entry] of this.cache.entries()) {
158
+ if (key.includes(componentTypeId)) {
159
+ keysToDelete.push(key);
160
+ }
161
+ }
162
+
163
+ keysToDelete.forEach(key => {
164
+ this.cache.delete(key);
165
+ logger.trace(`Invalidated cache entry due to component change: ${key.substring(0, 50)}...`);
166
+ });
167
+
168
+ logger.info(`Invalidated ${keysToDelete.length} cache entries for component: ${componentTypeId}`);
169
+ }
170
+
171
+ /**
172
+ * Clear entire cache
173
+ */
174
+ public clear(): void {
175
+ const size = this.cache.size;
176
+ this.cache.clear();
177
+ logger.info(`Cleared prepared statement cache (${size} entries)`);
178
+ }
179
+
180
+ /**
181
+ * Get cache statistics
182
+ */
183
+ public getStats(): CacheStats {
184
+ const totalRequests = this.stats.hits + this.stats.misses;
185
+ const hitRate = totalRequests > 0 ? this.stats.hits / totalRequests : 0;
186
+
187
+ return {
188
+ size: this.cache.size,
189
+ maxSize: this.maxSize,
190
+ hits: this.stats.hits,
191
+ misses: this.stats.misses,
192
+ evictions: this.stats.evictions,
193
+ totalPlanningTimeSaved: this.stats.totalPlanningTimeSaved,
194
+ averagePlanningTimeSaved: this.stats.totalPlanningTimeSaved / Math.max(this.stats.hits, 1)
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Warm up cache with common query patterns
200
+ */
201
+ public async warmUp(commonQueries: Array<{ sql: string; key: string }>, db: any): Promise<void> {
202
+ logger.info(`Warming up prepared statement cache with ${commonQueries.length} queries`);
203
+
204
+ for (const query of commonQueries) {
205
+ try {
206
+ await this.getOrCreate(query.sql, query.key, db);
207
+ } catch (error) {
208
+ logger.warn(`Failed to warm up query: ${error}`);
209
+ }
210
+ }
211
+
212
+ logger.info(`Cache warm-up complete. Cache size: ${this.cache.size}`);
213
+ }
214
+
215
+ /**
216
+ * Record planning time saved for metrics
217
+ */
218
+ public recordPlanningTimeSaved(timeMs: number): void {
219
+ this.stats.totalPlanningTimeSaved += timeMs;
220
+ }
221
+ }
222
+
223
+ // Global cache instance
224
+ export const preparedStatementCache = new PreparedStatementCache(
225
+ parseInt(process.env.BUNSANE_QUERY_CACHE_SIZE || '100', 10)
226
+ );
package/database/index.ts CHANGED
@@ -1,17 +1,38 @@
1
1
  import {SQL} from "bun";
2
- import { logger } from "core/Logger";
2
+ import { logger } from "../core/Logger";
3
3
 
4
4
  let connectionUrl = `postgres://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB}`;
5
5
  if(process.env.DB_CONNECTION_URL) {
6
6
  connectionUrl = process.env.DB_CONNECTION_URL;
7
7
  }
8
- logger.info(`Database connection URL: ${connectionUrl}`);
8
+
9
+ // Add statement_timeout only when explicitly configured (opt-in)
10
+ // Note: PgBouncer rejects statement_timeout as a startup parameter — use PostgreSQL config or connect_query instead
11
+ if (process.env.USE_PGLITE !== 'true' && process.env.DB_STATEMENT_TIMEOUT) {
12
+ try {
13
+ const urlObj = new URL(connectionUrl);
14
+ urlObj.searchParams.set('options', `-c statement_timeout=${process.env.DB_STATEMENT_TIMEOUT}`);
15
+ connectionUrl = urlObj.toString();
16
+ } catch {
17
+ // Non-standard URL format, skip statement_timeout
18
+ }
19
+ }
20
+
21
+ // Log connection URL with credentials redacted
22
+ const redactedUrl = connectionUrl.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:****@');
23
+ logger.info(`Database connection URL: ${redactedUrl}`);
24
+
25
+ // OPTIMIZED: Reduced from 20 to 10 to prevent overwhelming PGBouncer
26
+ // With 5 app instances: 5 × 10 = 50 connections (well under PGBouncer's limit)
27
+ const maxConnections = parseInt(process.env.POSTGRES_MAX_CONNECTIONS ?? '10', 10);
28
+ logger.info(`Connection pool size: ${maxConnections} connections`);
29
+
9
30
  const db = new SQL({
10
31
  url: connectionUrl,
11
- // Connection pool settings - FIXED
12
- max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS ?? '20', 10), // Increased max connections
13
- idleTimeout: 30000, // Close idle connections after 30s (was 0)
14
- maxLifetime: 600000, // Connection lifetime 10 minutes (was 0 = forever)
32
+ // Connection pool settings - OPTIMIZED for PGBouncer
33
+ max: maxConnections,
34
+ idleTimeout: 30000, // Close idle connections after 30s
35
+ maxLifetime: 600000, // Connection lifetime 10 minutes
15
36
  connectionTimeout: 30, // Timeout when establishing new connections
16
37
  onclose: (err) => {
17
38
  if (err) {
@@ -22,9 +43,13 @@ const db = new SQL({
22
43
  logger.error(err);
23
44
  }
24
45
  } else {
25
- logger.info("Database connection closed.");
46
+ logger.trace("Database connection closed gracefully.");
26
47
  }
27
48
  },
49
+ onconnect: () => {
50
+ // Log when new connections are created
51
+ logger.trace("New database connection established");
52
+ }
28
53
  });
29
54
 
30
55
 
@@ -1,5 +1,17 @@
1
1
  export function inList<T>(values: T[], paramIndex: number): { sql: string, params: any[], newParamIndex: number } {
2
2
  if (values.length === 0) return { sql: '()', params: [], newParamIndex: paramIndex };
3
- const placeholders = Array.from({length: values.length}, (_, i) => `$${paramIndex + i}`).join(', ');
4
- return { sql: `(${placeholders})`, params: values, newParamIndex: paramIndex + values.length };
3
+
4
+ // Filter out empty strings to prevent PostgreSQL UUID parsing errors
5
+ const filteredValues = values.filter(v => {
6
+ if (v === '' || (typeof v === 'string' && v.trim() === '')) {
7
+ console.error(`[sqlHelpers.inList] Empty string value detected in array, filtering out`);
8
+ return false;
9
+ }
10
+ return true;
11
+ });
12
+
13
+ if (filteredValues.length === 0) return { sql: '()', params: [], newParamIndex: paramIndex };
14
+
15
+ const placeholders = Array.from({length: filteredValues.length}, (_, i) => `$${paramIndex + i}`).join(', ');
16
+ return { sql: `(${placeholders})`, params: filteredValues, newParamIndex: paramIndex + filteredValues.length };
5
17
  }