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
|
@@ -13,4 +13,13 @@ export interface ComponentPropertyMetadata {
|
|
|
13
13
|
isEnum: boolean;
|
|
14
14
|
enumValues?: string[];
|
|
15
15
|
enumKeys?: string[];
|
|
16
|
+
isOptional: boolean;
|
|
17
|
+
arrayOf?: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IndexedFieldMetadata {
|
|
21
|
+
componentId: string;
|
|
22
|
+
propertyKey: string;
|
|
23
|
+
indexType: 'gin' | 'btree' | 'hash' | 'numeric';
|
|
24
|
+
isDateField: boolean;
|
|
16
25
|
}
|
package/core/metadata/index.ts
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import "reflect-metadata";
|
|
2
|
-
|
|
2
|
+
import { getMetadataStorage } from "./getMetadataStorage";
|
|
3
|
+
|
|
4
|
+
export { getMetadataStorage } from "./getMetadataStorage";
|
|
5
|
+
|
|
6
|
+
function toFieldLabel(fieldName: string): string {
|
|
7
|
+
let label = fieldName.replace(/_/g, ' ');
|
|
8
|
+
label = label.split(' ').map(word => word === 'id' ? 'ID' : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
|
|
9
|
+
return label;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getSerializedMetadataStorage(): {
|
|
13
|
+
archeTypes: Record<
|
|
14
|
+
string,
|
|
15
|
+
{
|
|
16
|
+
fieldName: string;
|
|
17
|
+
componentName: string;
|
|
18
|
+
fieldLabel: string;
|
|
19
|
+
nullable?: boolean;
|
|
20
|
+
}[]
|
|
21
|
+
>;
|
|
22
|
+
} {
|
|
23
|
+
const storage = getMetadataStorage();
|
|
24
|
+
const archeTypes: Record<string, any> = {};
|
|
25
|
+
|
|
26
|
+
storage.archetypes_field_map.forEach((v, k) => {
|
|
27
|
+
archeTypes[k] = v.map((value) => {
|
|
28
|
+
return {
|
|
29
|
+
fieldName: value.fieldName,
|
|
30
|
+
componentName: value.component.name,
|
|
31
|
+
fieldLabel: toFieldLabel(value.fieldName),
|
|
32
|
+
nullable: value.options?.nullable,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// console.log(archeTypes, 'archeTypes');
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
archeTypes,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
3
44
|
export function Enum() {
|
|
4
45
|
return (target: any) => {
|
|
5
46
|
Reflect.defineMetadata("isEnum", true, target);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
import type {
|
|
3
3
|
ComponentMetadata,
|
|
4
|
-
ComponentPropertyMetadata
|
|
4
|
+
ComponentPropertyMetadata,
|
|
5
|
+
IndexedFieldMetadata
|
|
5
6
|
} from "./definitions/Component";
|
|
6
7
|
import type { ArcheTypeMetadata, ArcheTypeFieldOptions } from './definitions/ArcheType';
|
|
7
8
|
import type { RelationOptions } from '../ArcheType';
|
|
@@ -18,6 +19,7 @@ export class MetadataStorage {
|
|
|
18
19
|
components: ComponentMetadata[] = [];
|
|
19
20
|
components_map: Map<string, ComponentMetadata> = new Map();
|
|
20
21
|
componentProperties: Map<string, ComponentPropertyMetadata[]> = new Map();
|
|
22
|
+
indexedFields: Map<string, IndexedFieldMetadata[]> = new Map();
|
|
21
23
|
archetypes: ArcheTypeMetadata[] = [];
|
|
22
24
|
archetypes_field_map: Map<string, ArcheTypeFieldMap[]> = new Map();
|
|
23
25
|
archetypes_relations_map: Map<string, ArcheTypeRelationMap[]> = new Map();
|
|
@@ -48,6 +50,17 @@ export class MetadataStorage {
|
|
|
48
50
|
this.componentProperties.get(metadata.component_id)!.push(metadata);
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
collectIndexedFieldMetadata(metadata: IndexedFieldMetadata) {
|
|
54
|
+
if(!this.indexedFields.has(metadata.componentId)) {
|
|
55
|
+
this.indexedFields.set(metadata.componentId, []);
|
|
56
|
+
}
|
|
57
|
+
this.indexedFields.get(metadata.componentId)!.push(metadata);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getIndexedFields(componentId: string): IndexedFieldMetadata[] {
|
|
61
|
+
return this.indexedFields.get(componentId) || [];
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
|
|
52
65
|
getComponentProperties(component_id: string): ComponentPropertyMetadata[] {
|
|
53
66
|
return this.componentProperties.get(component_id) || [];
|
|
@@ -75,7 +88,20 @@ export class MetadataStorage {
|
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
collectArcheTypeMetadata(metadata: ArcheTypeMetadata) {
|
|
78
|
-
|
|
91
|
+
// Check if archetype already exists and update it
|
|
92
|
+
const existingIndex = this.archetypes.findIndex(
|
|
93
|
+
a => a.typeId === metadata.typeId
|
|
94
|
+
);
|
|
95
|
+
if (existingIndex !== -1) {
|
|
96
|
+
// Update existing metadata
|
|
97
|
+
const existing = this.archetypes[existingIndex];
|
|
98
|
+
if (existing && metadata.functions) {
|
|
99
|
+
existing.functions = metadata.functions;
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// Add new metadata
|
|
103
|
+
this.archetypes.push(metadata);
|
|
104
|
+
}
|
|
79
105
|
}
|
|
80
106
|
}
|
|
81
107
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Middleware } from '../Middleware';
|
|
2
|
+
import { logger as MainLogger } from '../Logger';
|
|
3
|
+
import { getRequestId } from './RequestId';
|
|
4
|
+
|
|
5
|
+
const logger = MainLogger.child({ scope: 'HTTP' });
|
|
6
|
+
|
|
7
|
+
export type AccessLogOptions = {
|
|
8
|
+
/** Paths to skip logging for (e.g., '/health'). Default: [] */
|
|
9
|
+
skip?: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function accessLog(options: AccessLogOptions = {}): Middleware {
|
|
13
|
+
const skipSet = new Set(options.skip || []);
|
|
14
|
+
|
|
15
|
+
return async (req, next) => {
|
|
16
|
+
const url = new URL(req.url);
|
|
17
|
+
if (skipSet.has(url.pathname)) {
|
|
18
|
+
return next();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const start = performance.now();
|
|
22
|
+
let response: Response;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
response = await next();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const duration = Math.round(performance.now() - start);
|
|
28
|
+
logger.error({
|
|
29
|
+
requestId: getRequestId(),
|
|
30
|
+
method: req.method,
|
|
31
|
+
path: url.pathname,
|
|
32
|
+
status: 500,
|
|
33
|
+
duration,
|
|
34
|
+
msg: `${req.method} ${url.pathname} 500 ${duration}ms`,
|
|
35
|
+
});
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const duration = Math.round(performance.now() - start);
|
|
40
|
+
const logData = {
|
|
41
|
+
requestId: getRequestId(),
|
|
42
|
+
method: req.method,
|
|
43
|
+
path: url.pathname,
|
|
44
|
+
status: response.status,
|
|
45
|
+
duration,
|
|
46
|
+
msg: `${req.method} ${url.pathname} ${response.status} ${duration}ms`,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (response.status >= 500) {
|
|
50
|
+
logger.error(logData);
|
|
51
|
+
} else if (response.status >= 400) {
|
|
52
|
+
logger.warn(logData);
|
|
53
|
+
} else {
|
|
54
|
+
logger.info(logData);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return response;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
import type { Middleware } from '../Middleware';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AsyncLocalStorage to propagate requestId to any code running within a request.
|
|
6
|
+
* Import this from your modules to access the current request's ID and logger.
|
|
7
|
+
*/
|
|
8
|
+
const requestStore = new AsyncLocalStorage<{ requestId: string }>();
|
|
9
|
+
|
|
10
|
+
export function getRequestId(): string | undefined {
|
|
11
|
+
return requestStore.getStore()?.requestId;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { requestStore };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Middleware that generates a unique request ID per request and stores it
|
|
18
|
+
* in AsyncLocalStorage so it's accessible anywhere in the call stack.
|
|
19
|
+
* Respects incoming X-Request-Id header (from load balancers/proxies).
|
|
20
|
+
*/
|
|
21
|
+
export function requestId(): Middleware {
|
|
22
|
+
return async (req, next) => {
|
|
23
|
+
const id = req.headers.get('X-Request-Id') || crypto.randomUUID();
|
|
24
|
+
|
|
25
|
+
return requestStore.run({ requestId: id }, async () => {
|
|
26
|
+
const response = await next();
|
|
27
|
+
|
|
28
|
+
const newHeaders = new Headers(response.headers);
|
|
29
|
+
newHeaders.set('X-Request-Id', id);
|
|
30
|
+
|
|
31
|
+
return new Response(response.body, {
|
|
32
|
+
status: response.status,
|
|
33
|
+
statusText: response.statusText,
|
|
34
|
+
headers: newHeaders,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Middleware } from '../Middleware';
|
|
2
|
+
|
|
3
|
+
export type SecurityHeadersOptions = {
|
|
4
|
+
/** Enable HSTS header. Default: true in production */
|
|
5
|
+
hsts?: boolean;
|
|
6
|
+
/** HSTS max-age in seconds. Default: 31536000 (1 year) */
|
|
7
|
+
hstsMaxAge?: number;
|
|
8
|
+
/** X-Frame-Options value. Default: 'DENY' */
|
|
9
|
+
frameOptions?: 'DENY' | 'SAMEORIGIN' | false;
|
|
10
|
+
/** X-Content-Type-Options. Default: true (sets 'nosniff') */
|
|
11
|
+
noSniff?: boolean;
|
|
12
|
+
/** Referrer-Policy value. Default: 'strict-origin-when-cross-origin' */
|
|
13
|
+
referrerPolicy?: string | false;
|
|
14
|
+
/** X-XSS-Protection. Default: false (deprecated header, modern browsers don't need it) */
|
|
15
|
+
xssProtection?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function securityHeaders(options: SecurityHeadersOptions = {}): Middleware {
|
|
19
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
20
|
+
const {
|
|
21
|
+
hsts = isProduction,
|
|
22
|
+
hstsMaxAge = 31536000,
|
|
23
|
+
frameOptions = 'DENY',
|
|
24
|
+
noSniff = true,
|
|
25
|
+
referrerPolicy = 'strict-origin-when-cross-origin',
|
|
26
|
+
xssProtection = false,
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
// Pre-compute headers once at registration time
|
|
30
|
+
const headersToSet: [string, string][] = [];
|
|
31
|
+
|
|
32
|
+
if (hsts) {
|
|
33
|
+
headersToSet.push(['Strict-Transport-Security', `max-age=${hstsMaxAge}; includeSubDomains`]);
|
|
34
|
+
}
|
|
35
|
+
if (frameOptions) {
|
|
36
|
+
headersToSet.push(['X-Frame-Options', frameOptions]);
|
|
37
|
+
}
|
|
38
|
+
if (noSniff) {
|
|
39
|
+
headersToSet.push(['X-Content-Type-Options', 'nosniff']);
|
|
40
|
+
}
|
|
41
|
+
if (referrerPolicy) {
|
|
42
|
+
headersToSet.push(['Referrer-Policy', referrerPolicy]);
|
|
43
|
+
}
|
|
44
|
+
if (xssProtection) {
|
|
45
|
+
headersToSet.push(['X-XSS-Protection', '1; mode=block']);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return async (req, next) => {
|
|
49
|
+
const response = await next();
|
|
50
|
+
|
|
51
|
+
const newHeaders = new Headers(response.headers);
|
|
52
|
+
for (const [key, value] of headersToSet) {
|
|
53
|
+
newHeaders.set(key, value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return new Response(response.body, {
|
|
57
|
+
status: response.status,
|
|
58
|
+
statusText: response.statusText,
|
|
59
|
+
headers: newHeaders,
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distributed Lock using PostgreSQL Advisory Locks
|
|
3
|
+
*
|
|
4
|
+
* PostgreSQL advisory locks are application-level locks that can be used
|
|
5
|
+
* to coordinate between multiple application instances. They are:
|
|
6
|
+
* - Session-based: automatically released when connection closes
|
|
7
|
+
* - Non-blocking with pg_try_advisory_lock
|
|
8
|
+
* - Perfect for distributed task scheduling
|
|
9
|
+
*
|
|
10
|
+
* @see https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import db from "../../database";
|
|
14
|
+
import { logger } from "../Logger";
|
|
15
|
+
|
|
16
|
+
const loggerInstance = logger.child({ scope: "DistributedLock" });
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Result of a lock acquisition attempt
|
|
20
|
+
*/
|
|
21
|
+
export interface LockResult {
|
|
22
|
+
acquired: boolean;
|
|
23
|
+
lockKey: bigint;
|
|
24
|
+
taskId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration for the distributed lock system
|
|
29
|
+
*/
|
|
30
|
+
export interface DistributedLockConfig {
|
|
31
|
+
/** Whether distributed locking is enabled */
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
/** Prefix for lock keys to avoid collisions with other applications */
|
|
34
|
+
lockKeyPrefix: number;
|
|
35
|
+
/** Whether to log lock acquisition/release events */
|
|
36
|
+
enableLogging: boolean;
|
|
37
|
+
/** Timeout for lock acquisition attempts in milliseconds (0 = no retry) */
|
|
38
|
+
lockTimeout: number;
|
|
39
|
+
/** Retry interval when lockTimeout > 0 */
|
|
40
|
+
retryInterval: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default configuration
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_LOCK_CONFIG: DistributedLockConfig = {
|
|
47
|
+
enabled: true,
|
|
48
|
+
lockKeyPrefix: 0x42554E53, // "BUNS" in hex as a namespace prefix
|
|
49
|
+
enableLogging: false,
|
|
50
|
+
lockTimeout: 0, // No retry by default - skip if can't acquire
|
|
51
|
+
retryInterval: 100,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Distributed Lock Manager using PostgreSQL Advisory Locks
|
|
56
|
+
*
|
|
57
|
+
* Provides distributed coordination for scheduled tasks across multiple
|
|
58
|
+
* application instances. Uses PostgreSQL's advisory lock system which
|
|
59
|
+
* guarantees that only one instance can hold a lock at a time.
|
|
60
|
+
*
|
|
61
|
+
* Advisory locks are automatically released when:
|
|
62
|
+
* - Explicitly unlocked via pg_advisory_unlock
|
|
63
|
+
* - The database session ends
|
|
64
|
+
* - The connection is closed
|
|
65
|
+
*/
|
|
66
|
+
export class DistributedLock {
|
|
67
|
+
private config: DistributedLockConfig;
|
|
68
|
+
private heldLocks: Set<string> = new Set();
|
|
69
|
+
|
|
70
|
+
constructor(config: Partial<DistributedLockConfig> = {}) {
|
|
71
|
+
this.config = { ...DEFAULT_LOCK_CONFIG, ...config };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate a consistent 64-bit lock key from a task ID
|
|
76
|
+
* Uses a simple hash function to convert string task IDs to bigints
|
|
77
|
+
*
|
|
78
|
+
* The lock key is composed of:
|
|
79
|
+
* - Upper 32 bits: lockKeyPrefix (namespace)
|
|
80
|
+
* - Lower 32 bits: hash of taskId
|
|
81
|
+
*/
|
|
82
|
+
private generateLockKey(taskId: string): bigint {
|
|
83
|
+
// Simple hash function for the task ID
|
|
84
|
+
let hash = 0;
|
|
85
|
+
for (let i = 0; i < taskId.length; i++) {
|
|
86
|
+
const char = taskId.charCodeAt(i);
|
|
87
|
+
hash = ((hash << 5) - hash) + char;
|
|
88
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
89
|
+
}
|
|
90
|
+
// Make it positive
|
|
91
|
+
hash = Math.abs(hash);
|
|
92
|
+
|
|
93
|
+
// Combine prefix (upper 32 bits) with hash (lower 32 bits)
|
|
94
|
+
const prefix = BigInt(this.config.lockKeyPrefix);
|
|
95
|
+
const hashBigInt = BigInt(hash >>> 0); // Ensure unsigned
|
|
96
|
+
return (prefix << 32n) | hashBigInt;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Try to acquire a distributed lock for a task
|
|
101
|
+
*
|
|
102
|
+
* Uses pg_try_advisory_lock which is non-blocking:
|
|
103
|
+
* - Returns true immediately if lock is available
|
|
104
|
+
* - Returns false immediately if lock is held by another session
|
|
105
|
+
*
|
|
106
|
+
* @param taskId The unique identifier for the task
|
|
107
|
+
* @returns LockResult indicating whether the lock was acquired
|
|
108
|
+
*/
|
|
109
|
+
async tryAcquire(taskId: string): Promise<LockResult> {
|
|
110
|
+
if (!this.config.enabled) {
|
|
111
|
+
return { acquired: true, lockKey: 0n, taskId };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lockKey = this.generateLockKey(taskId);
|
|
115
|
+
const startTime = Date.now();
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// Try to acquire the lock
|
|
119
|
+
let acquired = await this.attemptLock(lockKey);
|
|
120
|
+
|
|
121
|
+
// If lockTimeout > 0, retry until timeout
|
|
122
|
+
if (!acquired && this.config.lockTimeout > 0) {
|
|
123
|
+
while (!acquired && (Date.now() - startTime) < this.config.lockTimeout) {
|
|
124
|
+
await this.sleep(this.config.retryInterval);
|
|
125
|
+
acquired = await this.attemptLock(lockKey);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (acquired) {
|
|
130
|
+
this.heldLocks.add(taskId);
|
|
131
|
+
if (this.config.enableLogging) {
|
|
132
|
+
loggerInstance.debug(`Acquired lock for task ${taskId} (key: ${lockKey})`);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
if (this.config.enableLogging) {
|
|
136
|
+
loggerInstance.debug(`Failed to acquire lock for task ${taskId} (key: ${lockKey}) - another instance is executing`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { acquired, lockKey, taskId };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
loggerInstance.error(`Error acquiring lock for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
143
|
+
// On error, return false to be safe (don't execute without lock)
|
|
144
|
+
return { acquired: false, lockKey, taskId };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Attempt to acquire the PostgreSQL advisory lock
|
|
150
|
+
*/
|
|
151
|
+
private async attemptLock(lockKey: bigint): Promise<boolean> {
|
|
152
|
+
const result = await db`
|
|
153
|
+
SELECT pg_try_advisory_lock(${lockKey}::bigint) as pg_try_advisory_lock
|
|
154
|
+
`;
|
|
155
|
+
return result[0]?.pg_try_advisory_lock ?? false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Release a distributed lock for a task
|
|
160
|
+
*
|
|
161
|
+
* Uses pg_advisory_unlock to explicitly release the lock.
|
|
162
|
+
* The lock is also automatically released if the connection closes.
|
|
163
|
+
*
|
|
164
|
+
* @param taskId The unique identifier for the task
|
|
165
|
+
* @returns true if the lock was released, false if it wasn't held
|
|
166
|
+
*/
|
|
167
|
+
async release(taskId: string): Promise<boolean> {
|
|
168
|
+
if (!this.config.enabled) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lockKey = this.generateLockKey(taskId);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const result = await db`
|
|
176
|
+
SELECT pg_advisory_unlock(${lockKey}::bigint) as pg_advisory_unlock
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const released = result[0]?.pg_advisory_unlock ?? false;
|
|
180
|
+
|
|
181
|
+
if (released) {
|
|
182
|
+
this.heldLocks.delete(taskId);
|
|
183
|
+
if (this.config.enableLogging) {
|
|
184
|
+
loggerInstance.debug(`Released lock for task ${taskId} (key: ${lockKey})`);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
if (this.config.enableLogging) {
|
|
188
|
+
loggerInstance.warn(`Lock for task ${taskId} was not held or already released`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return released;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
loggerInstance.error(`Error releasing lock for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
this.heldLocks.delete(taskId); // Remove from tracking even on error
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Release all locks held by this instance
|
|
202
|
+
* Useful during shutdown
|
|
203
|
+
*/
|
|
204
|
+
async releaseAll(): Promise<void> {
|
|
205
|
+
const tasks = Array.from(this.heldLocks);
|
|
206
|
+
for (const taskId of tasks) {
|
|
207
|
+
await this.release(taskId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if a lock is currently held (locally tracked)
|
|
213
|
+
*/
|
|
214
|
+
isHeld(taskId: string): boolean {
|
|
215
|
+
return this.heldLocks.has(taskId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get the count of locks held by this instance
|
|
220
|
+
*/
|
|
221
|
+
getHeldLockCount(): number {
|
|
222
|
+
return this.heldLocks.size;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Update the configuration
|
|
227
|
+
*/
|
|
228
|
+
updateConfig(config: Partial<DistributedLockConfig>): void {
|
|
229
|
+
this.config = { ...this.config, ...config };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get current configuration
|
|
234
|
+
*/
|
|
235
|
+
getConfig(): DistributedLockConfig {
|
|
236
|
+
return { ...this.config };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private sleep(ms: number): Promise<void> {
|
|
240
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Singleton instance for global access
|
|
246
|
+
*/
|
|
247
|
+
let distributedLockInstance: DistributedLock | null = null;
|
|
248
|
+
|
|
249
|
+
export function getDistributedLock(config?: Partial<DistributedLockConfig>): DistributedLock {
|
|
250
|
+
if (!distributedLockInstance) {
|
|
251
|
+
distributedLockInstance = new DistributedLock(config);
|
|
252
|
+
} else if (config) {
|
|
253
|
+
distributedLockInstance.updateConfig(config);
|
|
254
|
+
}
|
|
255
|
+
return distributedLockInstance;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Reset the singleton instance (useful for testing)
|
|
260
|
+
*/
|
|
261
|
+
export function resetDistributedLock(): void {
|
|
262
|
+
if (distributedLockInstance) {
|
|
263
|
+
distributedLockInstance.releaseAll().catch(() => {});
|
|
264
|
+
distributedLockInstance = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler Module
|
|
3
|
+
*
|
|
4
|
+
* Provides distributed task scheduling capabilities for multi-instance deployments.
|
|
5
|
+
* Uses PostgreSQL advisory locks to ensure only one instance executes a task at a time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
DistributedLock,
|
|
10
|
+
getDistributedLock,
|
|
11
|
+
resetDistributedLock,
|
|
12
|
+
DEFAULT_LOCK_CONFIG,
|
|
13
|
+
type DistributedLockConfig,
|
|
14
|
+
type LockResult,
|
|
15
|
+
} from './DistributedLock';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const envSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
// DB connection: either URL or individual fields
|
|
6
|
+
DB_CONNECTION_URL: z.string().url().optional(),
|
|
7
|
+
POSTGRES_HOST: z.string().optional(),
|
|
8
|
+
POSTGRES_USER: z.string().optional(),
|
|
9
|
+
POSTGRES_PASSWORD: z.string().optional(),
|
|
10
|
+
POSTGRES_DB: z.string().optional(),
|
|
11
|
+
POSTGRES_PORT: z
|
|
12
|
+
.string()
|
|
13
|
+
.regex(/^\d+$/, "POSTGRES_PORT must be numeric")
|
|
14
|
+
.optional(),
|
|
15
|
+
POSTGRES_MAX_CONNECTIONS: z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(/^\d+$/, "POSTGRES_MAX_CONNECTIONS must be numeric")
|
|
18
|
+
.optional(),
|
|
19
|
+
|
|
20
|
+
// App config
|
|
21
|
+
APP_PORT: z
|
|
22
|
+
.string()
|
|
23
|
+
.regex(/^\d+$/, "APP_PORT must be numeric")
|
|
24
|
+
.optional(),
|
|
25
|
+
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
|
|
26
|
+
|
|
27
|
+
// GraphQL
|
|
28
|
+
GRAPHQL_MAX_DEPTH: z
|
|
29
|
+
.string()
|
|
30
|
+
.regex(/^\d+$/, "GRAPHQL_MAX_DEPTH must be numeric")
|
|
31
|
+
.optional(),
|
|
32
|
+
|
|
33
|
+
// S3 Storage (opt-in)
|
|
34
|
+
S3_BUCKET: z.string().optional(),
|
|
35
|
+
S3_REGION: z.string().optional(),
|
|
36
|
+
S3_ENDPOINT: z.string().optional(),
|
|
37
|
+
S3_ACCESS_KEY_ID: z.string().optional(),
|
|
38
|
+
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
|
39
|
+
|
|
40
|
+
// HTTP
|
|
41
|
+
MAX_REQUEST_BODY_SIZE: z
|
|
42
|
+
.string()
|
|
43
|
+
.regex(/^\d+$/, "MAX_REQUEST_BODY_SIZE must be numeric")
|
|
44
|
+
.optional(),
|
|
45
|
+
|
|
46
|
+
// Operational
|
|
47
|
+
SHUTDOWN_GRACE_PERIOD_MS: z
|
|
48
|
+
.string()
|
|
49
|
+
.regex(/^\d+$/, "SHUTDOWN_GRACE_PERIOD_MS must be numeric")
|
|
50
|
+
.optional(),
|
|
51
|
+
DB_STATEMENT_TIMEOUT: z
|
|
52
|
+
.string()
|
|
53
|
+
.regex(/^\d+$/, "DB_STATEMENT_TIMEOUT must be numeric")
|
|
54
|
+
.optional(),
|
|
55
|
+
})
|
|
56
|
+
.refine(
|
|
57
|
+
(env) => {
|
|
58
|
+
const hasUrl = !!env.DB_CONNECTION_URL;
|
|
59
|
+
const hasFields =
|
|
60
|
+
!!env.POSTGRES_HOST && !!env.POSTGRES_USER && !!env.POSTGRES_DB;
|
|
61
|
+
return hasUrl || hasFields;
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
message:
|
|
65
|
+
"Database connection required: provide DB_CONNECTION_URL or POSTGRES_HOST + POSTGRES_USER + POSTGRES_DB",
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
.refine(
|
|
69
|
+
(env) => {
|
|
70
|
+
if (env.S3_BUCKET) {
|
|
71
|
+
return !!env.S3_ACCESS_KEY_ID && !!env.S3_SECRET_ACCESS_KEY;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
message:
|
|
77
|
+
"S3_BUCKET requires S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY (or use IAM roles and omit S3_BUCKET from env)",
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export function validateEnv(): void {
|
|
82
|
+
const result = envSchema.safeParse(process.env);
|
|
83
|
+
if (!result.success) {
|
|
84
|
+
const messages = result.error.issues.map(
|
|
85
|
+
(issue) =>
|
|
86
|
+
` - ${issue.path.length ? issue.path.join(".") + ": " : ""}${issue.message}`,
|
|
87
|
+
);
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Environment validation failed:\n${messages.join("\n")}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|