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
package/scheduler/index.ts
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
import { ScheduleInterval } from "../types/scheduler.types";
|
|
2
2
|
import type { ScheduledTaskOptions } from "../types/scheduler.types";
|
|
3
|
-
import { SchedulerManager } from "core/SchedulerManager";
|
|
4
|
-
import { logger } from "core/Logger";
|
|
5
|
-
import type { ComponentTargetConfig } from "core/EntityHookManager";
|
|
3
|
+
import { SchedulerManager } from "../core/SchedulerManager";
|
|
4
|
+
import { logger } from "../core/Logger";
|
|
5
|
+
import type { ComponentTargetConfig } from "../core/EntityHookManager";
|
|
6
6
|
const loggerInstance = logger.child({ scope: "ScheduledTaskDecorator" });
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Decorator for registering scheduled tasks
|
|
10
|
-
* @param options Task configuration options including interval and
|
|
10
|
+
* @param options Task configuration options including interval and query function
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* @ScheduledTask({
|
|
14
|
+
* interval: ScheduleInterval.MINUTE,
|
|
15
|
+
* query: () => {
|
|
16
|
+
* return new Query()
|
|
17
|
+
* .with(SessionComponent)
|
|
18
|
+
* .with(PhoneComponent)
|
|
19
|
+
* .without(AuthenticatedTag);
|
|
20
|
+
* }
|
|
21
|
+
* })
|
|
22
|
+
* async myTask(entities: Entity[]) {
|
|
23
|
+
* // Process entities
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
11
26
|
*/
|
|
12
27
|
export function ScheduledTask(
|
|
13
28
|
options: ScheduledTaskOptions & {
|
|
14
|
-
interval: ScheduleInterval;
|
|
15
|
-
componentTarget?: ComponentTargetConfig
|
|
29
|
+
interval: ScheduleInterval;
|
|
16
30
|
}
|
|
17
31
|
) {
|
|
18
32
|
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
@@ -29,8 +43,8 @@ export function ScheduledTask(
|
|
|
29
43
|
const taskInfo = {
|
|
30
44
|
id: taskId,
|
|
31
45
|
name: options.name || `${target.constructor.name}.${propertyKey}`,
|
|
32
|
-
componentTarget: options.componentTarget, // Legacy support
|
|
33
46
|
interval: options.interval,
|
|
47
|
+
cronExpression: options.cronExpression,
|
|
34
48
|
options: {
|
|
35
49
|
runOnStart: options.runOnStart ?? false,
|
|
36
50
|
timeout: options.timeout ?? 30000,
|
|
@@ -45,7 +59,15 @@ export function ScheduledTask(
|
|
|
45
59
|
enabled: true
|
|
46
60
|
};
|
|
47
61
|
|
|
48
|
-
|
|
62
|
+
// Check if task with this ID already exists in the array to prevent duplicates
|
|
63
|
+
const existingTaskIndex = target.constructor.__scheduledTasks.findIndex(
|
|
64
|
+
(t: any) => t.id === taskId
|
|
65
|
+
);
|
|
66
|
+
if (existingTaskIndex === -1) {
|
|
67
|
+
target.constructor.__scheduledTasks.push(taskInfo);
|
|
68
|
+
} else {
|
|
69
|
+
loggerInstance.warn(`Task ${taskId} already exists in __scheduledTasks array. Skipping duplicate.`);
|
|
70
|
+
}
|
|
49
71
|
|
|
50
72
|
// Return the original descriptor to maintain method functionality
|
|
51
73
|
return descriptor;
|
|
@@ -65,7 +87,17 @@ export function registerScheduledTasks(service: any): void {
|
|
|
65
87
|
|
|
66
88
|
const scheduler = SchedulerManager.getInstance();
|
|
67
89
|
|
|
90
|
+
// Deduplicate tasks by ID to prevent duplicate registrations
|
|
91
|
+
const uniqueTasks = new Map<string, any>();
|
|
68
92
|
for (const task of constructor.__scheduledTasks) {
|
|
93
|
+
if (!uniqueTasks.has(task.id)) {
|
|
94
|
+
uniqueTasks.set(task.id, task);
|
|
95
|
+
} else {
|
|
96
|
+
loggerInstance.warn(`Duplicate task found in __scheduledTasks array: ${task.id}. Using first occurrence only.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const task of uniqueTasks.values()) {
|
|
69
101
|
const taskWithService = {
|
|
70
102
|
...task,
|
|
71
103
|
service: service
|
package/service/Service.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { GraphQLObjectTypeMeta, GraphQLOperationMeta } from "gql/Generator";
|
|
1
|
+
import type { GraphQLObjectTypeMeta, GraphQLOperationMeta, GraphQLSubscriptionMeta } from "../gql/Generator";
|
|
2
2
|
|
|
3
3
|
class BaseService {
|
|
4
4
|
public __graphqlObjectType?: GraphQLObjectTypeMeta[];
|
|
5
5
|
public __graphqlOperations?: GraphQLOperationMeta<any>[];
|
|
6
|
+
public __graphqlSubscriptions?: GraphQLSubscriptionMeta<any>[];
|
|
6
7
|
constructor() {
|
|
7
8
|
|
|
8
9
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type BaseService from "./Service";
|
|
2
|
-
import ApplicationLifecycle, {ApplicationPhase} from "core/ApplicationLifecycle";
|
|
3
|
-
import {
|
|
2
|
+
import ApplicationLifecycle, {ApplicationPhase} from "../core/ApplicationLifecycle";
|
|
3
|
+
import { generateGraphQLSchemaV2 } from "../gql";
|
|
4
4
|
import { GraphQLSchema } from "graphql";
|
|
5
5
|
|
|
6
6
|
class ServiceRegistry {
|
|
@@ -19,11 +19,12 @@ class ServiceRegistry {
|
|
|
19
19
|
switch(event.detail) {
|
|
20
20
|
case ApplicationPhase.SYSTEM_REGISTERING: {
|
|
21
21
|
const servicesArray = Array.from(this.services.values());
|
|
22
|
-
|
|
23
|
-
const
|
|
22
|
+
|
|
23
|
+
const result = generateGraphQLSchemaV2(servicesArray, {
|
|
24
24
|
enableArchetypeOperations: false
|
|
25
25
|
});
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
this.schema = result.schema;
|
|
27
28
|
ApplicationLifecycle.setPhase(ApplicationPhase.SYSTEM_READY);
|
|
28
29
|
break;
|
|
29
30
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { StorageProvider } from "./StorageProvider";
|
|
4
|
-
import type { UploadConfiguration, StorageResult, FileMetadata } from "
|
|
5
|
-
import { logger as MainLogger } from "../Logger";
|
|
4
|
+
import type { UploadConfiguration, StorageResult, FileMetadata } from "../types/upload.types";
|
|
5
|
+
import { logger as MainLogger } from "../core/Logger";
|
|
6
6
|
|
|
7
7
|
const logger = MainLogger.child({ scope: "LocalStorageProvider" });
|
|
8
8
|
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { S3Client } from "bun";
|
|
2
|
+
import { StorageProvider } from "./StorageProvider";
|
|
3
|
+
import type {
|
|
4
|
+
UploadConfiguration,
|
|
5
|
+
StorageResult,
|
|
6
|
+
FileMetadata,
|
|
7
|
+
} from "../types/upload.types";
|
|
8
|
+
import { logger as MainLogger } from "../core/Logger";
|
|
9
|
+
|
|
10
|
+
const logger = MainLogger.child({ scope: "S3StorageProvider" });
|
|
11
|
+
|
|
12
|
+
export interface S3StorageConfig {
|
|
13
|
+
bucket: string;
|
|
14
|
+
region?: string;
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
accessKeyId?: string;
|
|
17
|
+
secretAccessKey?: string;
|
|
18
|
+
sessionToken?: string;
|
|
19
|
+
acl?: "private" | "public-read";
|
|
20
|
+
keyPrefix?: string;
|
|
21
|
+
/** Presign expiry in seconds for private objects (default: 3600) */
|
|
22
|
+
presignExpiry?: number;
|
|
23
|
+
/** Presign expiry in seconds for public-read objects (default: 86400 / 24h) */
|
|
24
|
+
publicPresignExpiry?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class S3StorageProvider extends StorageProvider {
|
|
28
|
+
private client: S3Client;
|
|
29
|
+
private bucket: string;
|
|
30
|
+
private acl: "private" | "public-read";
|
|
31
|
+
private keyPrefix: string;
|
|
32
|
+
private presignExpiry: number;
|
|
33
|
+
private publicPresignExpiry: number;
|
|
34
|
+
|
|
35
|
+
constructor(config: S3StorageConfig, client?: S3Client) {
|
|
36
|
+
super("s3", config);
|
|
37
|
+
this.bucket = config.bucket;
|
|
38
|
+
this.acl = config.acl ?? "private";
|
|
39
|
+
// Normalize keyPrefix to ensure trailing /
|
|
40
|
+
const prefix = config.keyPrefix ?? "";
|
|
41
|
+
this.keyPrefix = prefix && !prefix.endsWith("/") ? `${prefix}/` : prefix;
|
|
42
|
+
this.presignExpiry = config.presignExpiry ?? 3600;
|
|
43
|
+
this.publicPresignExpiry = config.publicPresignExpiry ?? 86400;
|
|
44
|
+
|
|
45
|
+
this.client =
|
|
46
|
+
client ??
|
|
47
|
+
new S3Client({
|
|
48
|
+
bucket: config.bucket,
|
|
49
|
+
region: config.region ?? process.env.S3_REGION,
|
|
50
|
+
endpoint: config.endpoint ?? process.env.S3_ENDPOINT,
|
|
51
|
+
accessKeyId:
|
|
52
|
+
config.accessKeyId ?? process.env.S3_ACCESS_KEY_ID,
|
|
53
|
+
secretAccessKey:
|
|
54
|
+
config.secretAccessKey ?? process.env.S3_SECRET_ACCESS_KEY,
|
|
55
|
+
sessionToken: config.sessionToken,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
this.validateConfig();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public async initialize(): Promise<void> {
|
|
62
|
+
logger.info(`Initializing S3 Storage Provider for bucket: ${this.bucket}`);
|
|
63
|
+
try {
|
|
64
|
+
await this.client.list({ maxKeys: 1 });
|
|
65
|
+
logger.info("S3 connectivity verified");
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const msg =
|
|
68
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
69
|
+
logger.error(`S3 connectivity check failed: ${msg}`);
|
|
70
|
+
throw new Error(`S3 initialization failed: ${msg}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public async store(
|
|
75
|
+
file: File,
|
|
76
|
+
metadata: FileMetadata,
|
|
77
|
+
config: UploadConfiguration,
|
|
78
|
+
): Promise<StorageResult> {
|
|
79
|
+
const key = this.buildKey(config.uploadPath, metadata.fileName);
|
|
80
|
+
|
|
81
|
+
logger.info({ key, size: metadata.size }, "Storing file to S3");
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// Pass File (Blob) directly — Bun streams it without full buffering
|
|
85
|
+
await this.client.write(key, file, {
|
|
86
|
+
type: metadata.mimeType,
|
|
87
|
+
acl: this.acl,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const url = this.buildFileUrl(key);
|
|
91
|
+
|
|
92
|
+
logger.info({ key }, "File stored successfully");
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
path: key,
|
|
96
|
+
url,
|
|
97
|
+
metadata: {
|
|
98
|
+
...metadata,
|
|
99
|
+
bucket: this.bucket,
|
|
100
|
+
s3Key: key,
|
|
101
|
+
storedAt: new Date().toISOString(),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// Attempt cleanup of any partial upload
|
|
106
|
+
try {
|
|
107
|
+
await this.client.delete(key);
|
|
108
|
+
} catch {
|
|
109
|
+
// Cleanup is best-effort
|
|
110
|
+
}
|
|
111
|
+
const msg =
|
|
112
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
113
|
+
logger.error({ key }, "Failed to store file to S3");
|
|
114
|
+
throw new Error(`Failed to store file to S3: ${msg}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public async delete(filePath: string): Promise<boolean> {
|
|
119
|
+
const key = this.resolveKey(filePath);
|
|
120
|
+
try {
|
|
121
|
+
await this.client.delete(key);
|
|
122
|
+
logger.info(`File deleted from S3: ${key}`);
|
|
123
|
+
return true;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const msg =
|
|
126
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
127
|
+
logger.error(`Failed to delete file ${key}: ${msg}`);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
public async getUrl(filePath: string): Promise<string> {
|
|
133
|
+
const key = this.resolveKey(filePath);
|
|
134
|
+
return this.buildFileUrl(key);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public async exists(filePath: string): Promise<boolean> {
|
|
138
|
+
const key = this.resolveKey(filePath);
|
|
139
|
+
try {
|
|
140
|
+
return await this.client.exists(key);
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public async getMetadata(filePath: string): Promise<FileMetadata | null> {
|
|
147
|
+
const key = this.resolveKey(filePath);
|
|
148
|
+
try {
|
|
149
|
+
const stat = await this.client.stat(key);
|
|
150
|
+
const fileName = key.split("/").pop() ?? key;
|
|
151
|
+
const extIdx = fileName.lastIndexOf(".");
|
|
152
|
+
const extension = extIdx > 0 ? fileName.slice(extIdx) : "";
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
uploadId: "",
|
|
156
|
+
fileName,
|
|
157
|
+
originalFileName: fileName,
|
|
158
|
+
mimeType: stat.type ?? "application/octet-stream",
|
|
159
|
+
size: stat.size,
|
|
160
|
+
extension,
|
|
161
|
+
uploadedAt: stat.lastModified
|
|
162
|
+
? stat.lastModified.toISOString()
|
|
163
|
+
: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const msg =
|
|
167
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
168
|
+
logger.error(`Failed to get metadata for ${key}: ${msg}`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public async list(prefix: string): Promise<string[]> {
|
|
174
|
+
const resolvedPrefix = this.keyPrefix + this.sanitizePath(prefix);
|
|
175
|
+
const keys: string[] = [];
|
|
176
|
+
let continuationToken: string | undefined;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
do {
|
|
180
|
+
const page = await this.client.list({
|
|
181
|
+
prefix: resolvedPrefix,
|
|
182
|
+
maxKeys: 1000,
|
|
183
|
+
continuationToken,
|
|
184
|
+
});
|
|
185
|
+
for (const obj of page.contents ?? []) {
|
|
186
|
+
keys.push(obj.key);
|
|
187
|
+
}
|
|
188
|
+
continuationToken = page.isTruncated
|
|
189
|
+
? page.nextContinuationToken
|
|
190
|
+
: undefined;
|
|
191
|
+
} while (continuationToken);
|
|
192
|
+
|
|
193
|
+
return keys;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const msg =
|
|
196
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
197
|
+
logger.error(`Failed to list prefix ${resolvedPrefix}: ${msg}`);
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
public async getStream(filePath: string): Promise<ReadableStream> {
|
|
203
|
+
const key = this.resolveKey(filePath);
|
|
204
|
+
const s3File = this.client.file(key);
|
|
205
|
+
return s3File.stream();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
public async copy(
|
|
209
|
+
sourcePath: string,
|
|
210
|
+
destinationPath: string,
|
|
211
|
+
): Promise<boolean> {
|
|
212
|
+
const sourceKey = this.resolveKey(sourcePath);
|
|
213
|
+
const destKey = this.resolveKey(destinationPath);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const [data, stat] = await Promise.all([
|
|
217
|
+
this.client.file(sourceKey).arrayBuffer(),
|
|
218
|
+
this.client.stat(sourceKey),
|
|
219
|
+
]);
|
|
220
|
+
await this.client.write(destKey, data, {
|
|
221
|
+
type: stat.type,
|
|
222
|
+
acl: this.acl,
|
|
223
|
+
});
|
|
224
|
+
logger.info({ sourceKey, destKey }, "File copied");
|
|
225
|
+
return true;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const msg =
|
|
228
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
229
|
+
logger.error({ sourceKey, destKey }, "Failed to copy file");
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
public async move(
|
|
235
|
+
sourcePath: string,
|
|
236
|
+
destinationPath: string,
|
|
237
|
+
): Promise<boolean> {
|
|
238
|
+
const success = await this.copy(sourcePath, destinationPath);
|
|
239
|
+
if (success) {
|
|
240
|
+
return await this.delete(sourcePath);
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
public async getStats(): Promise<{
|
|
246
|
+
totalFiles: number;
|
|
247
|
+
totalSize: number;
|
|
248
|
+
}> {
|
|
249
|
+
let totalFiles = 0;
|
|
250
|
+
let totalSize = 0;
|
|
251
|
+
let continuationToken: string | undefined;
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
do {
|
|
255
|
+
const page = await this.client.list({
|
|
256
|
+
prefix: this.keyPrefix,
|
|
257
|
+
maxKeys: 1000,
|
|
258
|
+
continuationToken,
|
|
259
|
+
});
|
|
260
|
+
for (const obj of page.contents ?? []) {
|
|
261
|
+
totalFiles++;
|
|
262
|
+
totalSize += obj.size ?? 0;
|
|
263
|
+
}
|
|
264
|
+
continuationToken = page.isTruncated
|
|
265
|
+
? page.nextContinuationToken
|
|
266
|
+
: undefined;
|
|
267
|
+
} while (continuationToken);
|
|
268
|
+
|
|
269
|
+
return { totalFiles, totalSize };
|
|
270
|
+
} catch (error) {
|
|
271
|
+
const msg =
|
|
272
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
273
|
+
logger.error(`Failed to get S3 stats: ${msg}`);
|
|
274
|
+
return { totalFiles: 0, totalSize: 0 };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public async cleanup(): Promise<void> {
|
|
279
|
+
// S3 lifecycle policies handle cleanup — no-op
|
|
280
|
+
logger.info("S3 storage cleanup — managed by S3 lifecycle policies");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
protected validateConfig(): boolean {
|
|
284
|
+
if (!this.bucket) {
|
|
285
|
+
throw new Error("S3StorageProvider: bucket is required");
|
|
286
|
+
}
|
|
287
|
+
if (this.presignExpiry <= 0) {
|
|
288
|
+
throw new Error("S3StorageProvider: presignExpiry must be positive");
|
|
289
|
+
}
|
|
290
|
+
if (this.publicPresignExpiry <= 0) {
|
|
291
|
+
throw new Error("S3StorageProvider: publicPresignExpiry must be positive");
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private buildKey(uploadPath: string, fileName: string): string {
|
|
297
|
+
const path = this.buildPath(uploadPath, fileName);
|
|
298
|
+
return `${this.keyPrefix}${this.sanitizePath(path)}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private resolveKey(filePath: string): string {
|
|
302
|
+
const sanitized = this.sanitizePath(filePath);
|
|
303
|
+
if (this.keyPrefix && !sanitized.startsWith(this.keyPrefix)) {
|
|
304
|
+
return `${this.keyPrefix}${sanitized}`;
|
|
305
|
+
}
|
|
306
|
+
return sanitized;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private buildFileUrl(key: string): string {
|
|
310
|
+
const expiresIn =
|
|
311
|
+
this.acl === "public-read"
|
|
312
|
+
? this.publicPresignExpiry
|
|
313
|
+
: this.presignExpiry;
|
|
314
|
+
return this.client.presign(key, { expiresIn, method: "GET" });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { UploadConfiguration, StorageResult, FileMetadata } from "
|
|
1
|
+
import type { UploadConfiguration, StorageResult, FileMetadata } from "../types/upload.types";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Abstract Storage Provider Interface
|
|
@@ -104,8 +104,12 @@ export abstract class StorageProvider {
|
|
|
104
104
|
* Sanitize path to prevent directory traversal
|
|
105
105
|
*/
|
|
106
106
|
protected sanitizePath(path: string): string {
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
let sanitized = path;
|
|
108
|
+
// Iterative removal to prevent bypass via nested payloads (e.g. "....//")
|
|
109
|
+
while (sanitized.includes('..')) {
|
|
110
|
+
sanitized = sanitized.replace(/\.\./g, '');
|
|
111
|
+
}
|
|
112
|
+
return sanitized
|
|
109
113
|
.replace(/\/+/g, '/')
|
|
110
114
|
.replace(/^\/+/, '');
|
|
111
115
|
}
|