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.
- package/.claude/settings.local.json +47 -0
- package/.claude/skills/update-memory.md +74 -0
- package/.prettierrc +4 -0
- package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
- package/.serena/memories/architecture.md +154 -0
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
- package/.serena/memories/code_style_and_conventions.md +76 -0
- package/.serena/memories/project_overview.md +43 -0
- package/.serena/memories/schema-dsl-plan.md +107 -0
- package/.serena/memories/suggested_commands.md +80 -0
- package/.serena/memories/typescript-compilation-status.md +54 -0
- package/.serena/project.yml +114 -0
- package/TODO.md +1 -7
- package/bun.lock +150 -4
- package/bunfig.toml +10 -0
- package/config/cache.config.ts +77 -0
- package/config/upload.config.ts +4 -5
- package/core/App.ts +870 -123
- package/core/ArcheType.ts +2268 -377
- package/core/BatchLoader.ts +181 -71
- package/core/Config.ts +153 -0
- package/core/Decorators.ts +4 -1
- package/core/Entity.ts +621 -92
- package/core/EntityHookManager.ts +1 -1
- package/core/EntityInterface.ts +3 -1
- package/core/EntityManager.ts +1 -13
- package/core/ErrorHandler.ts +8 -2
- package/core/Logger.ts +9 -0
- package/core/Middleware.ts +34 -0
- package/core/RequestContext.ts +5 -1
- package/core/RequestLoaders.ts +227 -93
- package/core/SchedulerManager.ts +193 -52
- package/core/cache/CacheAnalytics.ts +399 -0
- package/core/cache/CacheFactory.ts +145 -0
- package/core/cache/CacheManager.ts +520 -0
- package/core/cache/CacheProvider.ts +34 -0
- package/core/cache/CacheWarmer.ts +157 -0
- package/core/cache/CompressionUtils.ts +110 -0
- package/core/cache/MemoryCache.ts +251 -0
- package/core/cache/MultiLevelCache.ts +180 -0
- package/core/cache/NoOpCache.ts +53 -0
- package/core/cache/RedisCache.ts +464 -0
- package/core/cache/TTLStrategy.ts +254 -0
- package/core/cache/index.ts +6 -0
- package/core/components/BaseComponent.ts +120 -0
- package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
- package/core/components/Decorators.ts +88 -0
- package/core/components/Interfaces.ts +7 -0
- package/core/components/index.ts +5 -0
- package/core/decorators/EntityHooks.ts +0 -3
- package/core/decorators/IndexedField.ts +26 -0
- package/core/decorators/ScheduledTask.ts +0 -47
- package/core/events/EntityLifecycleEvents.ts +1 -1
- package/core/health.ts +112 -0
- package/core/metadata/definitions/ArcheType.ts +14 -0
- package/core/metadata/definitions/Component.ts +9 -0
- package/core/metadata/definitions/gqlObject.ts +1 -1
- package/core/metadata/index.ts +42 -1
- package/core/metadata/metadata-storage.ts +28 -2
- package/core/middleware/AccessLog.ts +59 -0
- package/core/middleware/RequestId.ts +38 -0
- package/core/middleware/SecurityHeaders.ts +62 -0
- package/core/middleware/index.ts +3 -0
- package/core/scheduler/DistributedLock.ts +266 -0
- package/core/scheduler/index.ts +15 -0
- package/core/validateEnv.ts +92 -0
- package/database/DatabaseHelper.ts +416 -40
- package/database/IndexingStrategy.ts +342 -0
- package/database/PreparedStatementCache.ts +226 -0
- package/database/index.ts +32 -7
- package/database/sqlHelpers.ts +14 -2
- package/endpoints/archetypes.ts +362 -0
- package/endpoints/components.ts +58 -0
- package/endpoints/entity.ts +80 -0
- package/endpoints/index.ts +27 -0
- package/endpoints/query.ts +93 -0
- package/endpoints/stats.ts +76 -0
- package/endpoints/tables.ts +212 -0
- package/endpoints/types.ts +155 -0
- package/gql/ArchetypeOperations.ts +32 -86
- package/gql/Generator.ts +27 -315
- package/gql/GeneratorV2.ts +37 -0
- package/gql/builders/InputTypeBuilder.ts +99 -0
- package/gql/builders/ResolverBuilder.ts +234 -0
- package/gql/builders/TypeDefBuilder.ts +105 -0
- package/gql/builders/index.ts +3 -0
- package/gql/decorators/Upload.ts +1 -1
- package/gql/depthLimit.ts +85 -0
- package/gql/graph/GraphNode.ts +224 -0
- package/gql/graph/SchemaGraph.ts +278 -0
- package/gql/helpers.ts +8 -2
- package/gql/index.ts +56 -4
- package/gql/middleware.ts +79 -0
- package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
- package/gql/orchestration/index.ts +1 -0
- package/gql/scanner/ServiceScanner.ts +347 -0
- package/gql/schema/index.ts +458 -0
- package/gql/strategies/TypeGenerationStrategy.ts +329 -0
- package/gql/types.ts +1 -0
- package/gql/utils/TypeSignature.ts +220 -0
- package/gql/utils/index.ts +1 -0
- package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
- package/gql/visitors/DeduplicationVisitor.ts +82 -0
- package/gql/visitors/GraphVisitor.ts +78 -0
- package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
- package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
- package/gql/visitors/TypeCollectorVisitor.ts +79 -0
- package/gql/visitors/VisitorComposer.ts +96 -0
- package/gql/visitors/index.ts +7 -0
- package/package.json +59 -37
- package/plugins/index.ts +2 -2
- package/query/CTENode.ts +97 -0
- package/query/ComponentInclusionNode.ts +689 -0
- package/query/FilterBuilder.ts +127 -0
- package/query/FilterBuilderRegistry.ts +202 -0
- package/query/OrNode.ts +517 -0
- package/query/OrQuery.ts +42 -0
- package/query/Query.ts +1022 -0
- package/query/QueryContext.ts +170 -0
- package/query/QueryDAG.ts +122 -0
- package/query/QueryNode.ts +65 -0
- package/query/SourceNode.ts +53 -0
- package/query/builders/FullTextSearchBuilder.ts +236 -0
- package/query/index.ts +21 -0
- package/scheduler/index.ts +40 -8
- package/service/Service.ts +2 -1
- package/service/ServiceRegistry.ts +6 -5
- package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
- package/storage/S3StorageProvider.ts +316 -0
- package/{core/storage → storage}/StorageProvider.ts +7 -3
- package/studio/bun.lock +482 -0
- package/studio/index.html +13 -0
- package/studio/package.json +39 -0
- package/studio/postcss.config.js +6 -0
- package/studio/src/components/DataTable.tsx +211 -0
- package/studio/src/components/Layout.tsx +13 -0
- package/studio/src/components/PageContainer.tsx +9 -0
- package/studio/src/components/PageHeader.tsx +13 -0
- package/studio/src/components/SearchBar.tsx +57 -0
- package/studio/src/components/Sidebar.tsx +294 -0
- package/studio/src/components/ui/button.tsx +56 -0
- package/studio/src/components/ui/checkbox.tsx +26 -0
- package/studio/src/components/ui/input.tsx +25 -0
- package/studio/src/hooks/useDataTable.ts +131 -0
- package/studio/src/index.css +36 -0
- package/studio/src/lib/api.ts +186 -0
- package/studio/src/lib/utils.ts +13 -0
- package/studio/src/main.tsx +17 -0
- package/studio/src/pages/ArcheType.tsx +239 -0
- package/studio/src/pages/Components.tsx +124 -0
- package/studio/src/pages/EntityInspector.tsx +302 -0
- package/studio/src/pages/QueryRunner.tsx +246 -0
- package/studio/src/pages/Table.tsx +94 -0
- package/studio/src/pages/Welcome.tsx +241 -0
- package/studio/src/routes.tsx +45 -0
- package/studio/src/store/archeTypeSettings.ts +30 -0
- package/studio/src/store/studio.ts +65 -0
- package/studio/src/utils/columnHelpers.tsx +114 -0
- package/studio/studio-instructions.md +81 -0
- package/studio/tailwind.config.js +77 -0
- package/studio/tsconfig.json +24 -0
- package/studio/utils.ts +54 -0
- package/studio/vite.config.js +19 -0
- package/swagger/generator.ts +1 -1
- package/tests/e2e/http.test.ts +126 -0
- package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
- package/tests/fixtures/components/TestOrder.ts +23 -0
- package/tests/fixtures/components/TestProduct.ts +23 -0
- package/tests/fixtures/components/TestUser.ts +20 -0
- package/tests/fixtures/components/index.ts +6 -0
- package/tests/graphql/SchemaGeneration.test.ts +90 -0
- package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
- package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
- package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
- package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
- package/tests/integration/entity/Entity.persistence.test.ts +333 -0
- package/tests/integration/query/Query.exec.test.ts +523 -0
- package/tests/pglite-setup.ts +61 -0
- package/tests/setup.ts +164 -0
- package/tests/stress/BenchmarkRunner.ts +203 -0
- package/tests/stress/DataSeeder.ts +190 -0
- package/tests/stress/StressTestReporter.ts +229 -0
- package/tests/stress/cursor-perf-test.ts +171 -0
- package/tests/stress/fixtures/StressTestComponents.ts +58 -0
- package/tests/stress/index.ts +7 -0
- package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
- package/tests/unit/BatchLoader.test.ts +82 -0
- package/tests/unit/archetype/ArcheType.test.ts +107 -0
- package/tests/unit/cache/CacheManager.test.ts +347 -0
- package/tests/unit/cache/MemoryCache.test.ts +260 -0
- package/tests/unit/cache/RedisCache.test.ts +411 -0
- package/tests/unit/entity/Entity.components.test.ts +244 -0
- package/tests/unit/entity/Entity.test.ts +345 -0
- package/tests/unit/gql/depthLimit.test.ts +203 -0
- package/tests/unit/gql/operationMiddleware.test.ts +293 -0
- package/tests/unit/health/Health.test.ts +129 -0
- package/tests/unit/middleware/AccessLog.test.ts +37 -0
- package/tests/unit/middleware/Middleware.test.ts +98 -0
- package/tests/unit/middleware/RequestId.test.ts +54 -0
- package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
- package/tests/unit/query/FilterBuilder.test.ts +111 -0
- package/tests/unit/query/Query.test.ts +308 -0
- package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
- package/tests/unit/schema/schema-integration.test.ts +426 -0
- package/tests/unit/schema/schema.test.ts +580 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
- package/tests/unit/upload/RestUpload.test.ts +267 -0
- package/tests/unit/validateEnv.test.ts +82 -0
- package/tests/utils/entity-tracker.ts +57 -0
- package/tests/utils/index.ts +13 -0
- package/tests/utils/test-context.ts +149 -0
- package/tsconfig.json +5 -1
- package/types/archetype.types.ts +6 -0
- package/types/hooks.types.ts +1 -1
- package/types/query.types.ts +110 -0
- package/types/scheduler.types.ts +68 -7
- package/types/upload.types.ts +1 -0
- package/{core → upload}/FileValidator.ts +10 -1
- package/upload/RestUpload.ts +130 -0
- package/{core/components → upload}/UploadComponent.ts +11 -11
- package/{core → upload}/UploadManager.ts +3 -3
- package/upload/index.ts +23 -7
- package/utils/UploadHelper.ts +27 -6
- package/utils/cronParser.ts +16 -6
- package/.github/workflows/deploy-docs.yml +0 -57
- package/core/Components.ts +0 -202
- package/core/EntityCache.ts +0 -15
- package/core/Query.ts +0 -880
- package/docs/README.md +0 -149
- package/docs/_coverpage.md +0 -36
- package/docs/_sidebar.md +0 -23
- package/docs/api/core.md +0 -568
- package/docs/api/hooks.md +0 -554
- package/docs/api/index.md +0 -222
- package/docs/api/query.md +0 -678
- package/docs/api/service.md +0 -744
- package/docs/core-concepts/archetypes.md +0 -512
- package/docs/core-concepts/components.md +0 -498
- package/docs/core-concepts/entity.md +0 -314
- package/docs/core-concepts/hooks.md +0 -683
- package/docs/core-concepts/query.md +0 -588
- package/docs/core-concepts/services.md +0 -647
- package/docs/examples/code-examples.md +0 -425
- package/docs/getting-started.md +0 -337
- package/docs/index.html +0 -97
- package/tests/bench/insert.bench.ts +0 -60
- package/tests/bench/relations.bench.ts +0 -270
- package/tests/bench/sorting.bench.ts +0 -416
- package/tests/component-hooks-simple.test.ts +0 -117
- package/tests/component-hooks.test.ts +0 -1461
- package/tests/component.test.ts +0 -339
- package/tests/errorHandling.test.ts +0 -155
- package/tests/hooks.test.ts +0 -667
- package/tests/query-sorting.test.ts +0 -101
- package/tests/query.test.ts +0 -81
- package/tests/relations.test.ts +0 -170
- 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
|
-
|
|
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 -
|
|
12
|
-
max:
|
|
13
|
-
idleTimeout: 30000, // Close idle connections after 30s
|
|
14
|
-
maxLifetime: 600000, // Connection lifetime 10 minutes
|
|
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.
|
|
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
|
|
package/database/sqlHelpers.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
}
|