bunsane 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/.claude/settings.local.json +47 -0
  2. package/.claude/skills/update-memory.md +74 -0
  3. package/.prettierrc +4 -0
  4. package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
  5. package/.serena/memories/architecture.md +154 -0
  6. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
  7. package/.serena/memories/code_style_and_conventions.md +76 -0
  8. package/.serena/memories/project_overview.md +43 -0
  9. package/.serena/memories/schema-dsl-plan.md +107 -0
  10. package/.serena/memories/suggested_commands.md +80 -0
  11. package/.serena/memories/typescript-compilation-status.md +54 -0
  12. package/.serena/project.yml +114 -0
  13. package/TODO.md +1 -7
  14. package/bun.lock +150 -4
  15. package/bunfig.toml +10 -0
  16. package/config/cache.config.ts +77 -0
  17. package/config/upload.config.ts +4 -5
  18. package/core/App.ts +870 -123
  19. package/core/ArcheType.ts +2268 -377
  20. package/core/BatchLoader.ts +181 -71
  21. package/core/Config.ts +153 -0
  22. package/core/Decorators.ts +4 -1
  23. package/core/Entity.ts +621 -92
  24. package/core/EntityHookManager.ts +1 -1
  25. package/core/EntityInterface.ts +3 -1
  26. package/core/EntityManager.ts +1 -13
  27. package/core/ErrorHandler.ts +8 -2
  28. package/core/Logger.ts +9 -0
  29. package/core/Middleware.ts +34 -0
  30. package/core/RequestContext.ts +5 -1
  31. package/core/RequestLoaders.ts +227 -93
  32. package/core/SchedulerManager.ts +193 -52
  33. package/core/cache/CacheAnalytics.ts +399 -0
  34. package/core/cache/CacheFactory.ts +145 -0
  35. package/core/cache/CacheManager.ts +520 -0
  36. package/core/cache/CacheProvider.ts +34 -0
  37. package/core/cache/CacheWarmer.ts +157 -0
  38. package/core/cache/CompressionUtils.ts +110 -0
  39. package/core/cache/MemoryCache.ts +251 -0
  40. package/core/cache/MultiLevelCache.ts +180 -0
  41. package/core/cache/NoOpCache.ts +53 -0
  42. package/core/cache/RedisCache.ts +464 -0
  43. package/core/cache/TTLStrategy.ts +254 -0
  44. package/core/cache/index.ts +6 -0
  45. package/core/components/BaseComponent.ts +120 -0
  46. package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
  47. package/core/components/Decorators.ts +88 -0
  48. package/core/components/Interfaces.ts +7 -0
  49. package/core/components/index.ts +5 -0
  50. package/core/decorators/EntityHooks.ts +0 -3
  51. package/core/decorators/IndexedField.ts +26 -0
  52. package/core/decorators/ScheduledTask.ts +0 -47
  53. package/core/events/EntityLifecycleEvents.ts +1 -1
  54. package/core/health.ts +112 -0
  55. package/core/metadata/definitions/ArcheType.ts +14 -0
  56. package/core/metadata/definitions/Component.ts +9 -0
  57. package/core/metadata/definitions/gqlObject.ts +1 -1
  58. package/core/metadata/index.ts +42 -1
  59. package/core/metadata/metadata-storage.ts +28 -2
  60. package/core/middleware/AccessLog.ts +59 -0
  61. package/core/middleware/RequestId.ts +38 -0
  62. package/core/middleware/SecurityHeaders.ts +62 -0
  63. package/core/middleware/index.ts +3 -0
  64. package/core/scheduler/DistributedLock.ts +266 -0
  65. package/core/scheduler/index.ts +15 -0
  66. package/core/validateEnv.ts +92 -0
  67. package/database/DatabaseHelper.ts +416 -40
  68. package/database/IndexingStrategy.ts +342 -0
  69. package/database/PreparedStatementCache.ts +226 -0
  70. package/database/index.ts +32 -7
  71. package/database/sqlHelpers.ts +14 -2
  72. package/endpoints/archetypes.ts +362 -0
  73. package/endpoints/components.ts +58 -0
  74. package/endpoints/entity.ts +80 -0
  75. package/endpoints/index.ts +27 -0
  76. package/endpoints/query.ts +93 -0
  77. package/endpoints/stats.ts +76 -0
  78. package/endpoints/tables.ts +212 -0
  79. package/endpoints/types.ts +155 -0
  80. package/gql/ArchetypeOperations.ts +32 -86
  81. package/gql/Generator.ts +27 -315
  82. package/gql/GeneratorV2.ts +37 -0
  83. package/gql/builders/InputTypeBuilder.ts +99 -0
  84. package/gql/builders/ResolverBuilder.ts +234 -0
  85. package/gql/builders/TypeDefBuilder.ts +105 -0
  86. package/gql/builders/index.ts +3 -0
  87. package/gql/decorators/Upload.ts +1 -1
  88. package/gql/depthLimit.ts +85 -0
  89. package/gql/graph/GraphNode.ts +224 -0
  90. package/gql/graph/SchemaGraph.ts +278 -0
  91. package/gql/helpers.ts +8 -2
  92. package/gql/index.ts +56 -4
  93. package/gql/middleware.ts +79 -0
  94. package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
  95. package/gql/orchestration/index.ts +1 -0
  96. package/gql/scanner/ServiceScanner.ts +347 -0
  97. package/gql/schema/index.ts +458 -0
  98. package/gql/strategies/TypeGenerationStrategy.ts +329 -0
  99. package/gql/types.ts +1 -0
  100. package/gql/utils/TypeSignature.ts +220 -0
  101. package/gql/utils/index.ts +1 -0
  102. package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
  103. package/gql/visitors/DeduplicationVisitor.ts +82 -0
  104. package/gql/visitors/GraphVisitor.ts +78 -0
  105. package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
  106. package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
  107. package/gql/visitors/TypeCollectorVisitor.ts +79 -0
  108. package/gql/visitors/VisitorComposer.ts +96 -0
  109. package/gql/visitors/index.ts +7 -0
  110. package/package.json +59 -37
  111. package/plugins/index.ts +2 -2
  112. package/query/CTENode.ts +97 -0
  113. package/query/ComponentInclusionNode.ts +689 -0
  114. package/query/FilterBuilder.ts +127 -0
  115. package/query/FilterBuilderRegistry.ts +202 -0
  116. package/query/OrNode.ts +517 -0
  117. package/query/OrQuery.ts +42 -0
  118. package/query/Query.ts +1022 -0
  119. package/query/QueryContext.ts +170 -0
  120. package/query/QueryDAG.ts +122 -0
  121. package/query/QueryNode.ts +65 -0
  122. package/query/SourceNode.ts +53 -0
  123. package/query/builders/FullTextSearchBuilder.ts +236 -0
  124. package/query/index.ts +21 -0
  125. package/scheduler/index.ts +40 -8
  126. package/service/Service.ts +2 -1
  127. package/service/ServiceRegistry.ts +6 -5
  128. package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
  129. package/storage/S3StorageProvider.ts +316 -0
  130. package/{core/storage → storage}/StorageProvider.ts +7 -3
  131. package/studio/bun.lock +482 -0
  132. package/studio/index.html +13 -0
  133. package/studio/package.json +39 -0
  134. package/studio/postcss.config.js +6 -0
  135. package/studio/src/components/DataTable.tsx +211 -0
  136. package/studio/src/components/Layout.tsx +13 -0
  137. package/studio/src/components/PageContainer.tsx +9 -0
  138. package/studio/src/components/PageHeader.tsx +13 -0
  139. package/studio/src/components/SearchBar.tsx +57 -0
  140. package/studio/src/components/Sidebar.tsx +294 -0
  141. package/studio/src/components/ui/button.tsx +56 -0
  142. package/studio/src/components/ui/checkbox.tsx +26 -0
  143. package/studio/src/components/ui/input.tsx +25 -0
  144. package/studio/src/hooks/useDataTable.ts +131 -0
  145. package/studio/src/index.css +36 -0
  146. package/studio/src/lib/api.ts +186 -0
  147. package/studio/src/lib/utils.ts +13 -0
  148. package/studio/src/main.tsx +17 -0
  149. package/studio/src/pages/ArcheType.tsx +239 -0
  150. package/studio/src/pages/Components.tsx +124 -0
  151. package/studio/src/pages/EntityInspector.tsx +302 -0
  152. package/studio/src/pages/QueryRunner.tsx +246 -0
  153. package/studio/src/pages/Table.tsx +94 -0
  154. package/studio/src/pages/Welcome.tsx +241 -0
  155. package/studio/src/routes.tsx +45 -0
  156. package/studio/src/store/archeTypeSettings.ts +30 -0
  157. package/studio/src/store/studio.ts +65 -0
  158. package/studio/src/utils/columnHelpers.tsx +114 -0
  159. package/studio/studio-instructions.md +81 -0
  160. package/studio/tailwind.config.js +77 -0
  161. package/studio/tsconfig.json +24 -0
  162. package/studio/utils.ts +54 -0
  163. package/studio/vite.config.js +19 -0
  164. package/swagger/generator.ts +1 -1
  165. package/tests/e2e/http.test.ts +126 -0
  166. package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
  167. package/tests/fixtures/components/TestOrder.ts +23 -0
  168. package/tests/fixtures/components/TestProduct.ts +23 -0
  169. package/tests/fixtures/components/TestUser.ts +20 -0
  170. package/tests/fixtures/components/index.ts +6 -0
  171. package/tests/graphql/SchemaGeneration.test.ts +90 -0
  172. package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
  173. package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
  174. package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
  175. package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
  176. package/tests/integration/entity/Entity.persistence.test.ts +333 -0
  177. package/tests/integration/query/Query.exec.test.ts +523 -0
  178. package/tests/pglite-setup.ts +61 -0
  179. package/tests/setup.ts +164 -0
  180. package/tests/stress/BenchmarkRunner.ts +203 -0
  181. package/tests/stress/DataSeeder.ts +190 -0
  182. package/tests/stress/StressTestReporter.ts +229 -0
  183. package/tests/stress/cursor-perf-test.ts +171 -0
  184. package/tests/stress/fixtures/StressTestComponents.ts +58 -0
  185. package/tests/stress/index.ts +7 -0
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
  187. package/tests/unit/BatchLoader.test.ts +82 -0
  188. package/tests/unit/archetype/ArcheType.test.ts +107 -0
  189. package/tests/unit/cache/CacheManager.test.ts +347 -0
  190. package/tests/unit/cache/MemoryCache.test.ts +260 -0
  191. package/tests/unit/cache/RedisCache.test.ts +411 -0
  192. package/tests/unit/entity/Entity.components.test.ts +244 -0
  193. package/tests/unit/entity/Entity.test.ts +345 -0
  194. package/tests/unit/gql/depthLimit.test.ts +203 -0
  195. package/tests/unit/gql/operationMiddleware.test.ts +293 -0
  196. package/tests/unit/health/Health.test.ts +129 -0
  197. package/tests/unit/middleware/AccessLog.test.ts +37 -0
  198. package/tests/unit/middleware/Middleware.test.ts +98 -0
  199. package/tests/unit/middleware/RequestId.test.ts +54 -0
  200. package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
  201. package/tests/unit/query/FilterBuilder.test.ts +111 -0
  202. package/tests/unit/query/Query.test.ts +308 -0
  203. package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
  204. package/tests/unit/schema/schema-integration.test.ts +426 -0
  205. package/tests/unit/schema/schema.test.ts +580 -0
  206. package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
  207. package/tests/unit/upload/RestUpload.test.ts +267 -0
  208. package/tests/unit/validateEnv.test.ts +82 -0
  209. package/tests/utils/entity-tracker.ts +57 -0
  210. package/tests/utils/index.ts +13 -0
  211. package/tests/utils/test-context.ts +149 -0
  212. package/tsconfig.json +5 -1
  213. package/types/archetype.types.ts +6 -0
  214. package/types/hooks.types.ts +1 -1
  215. package/types/query.types.ts +110 -0
  216. package/types/scheduler.types.ts +68 -7
  217. package/types/upload.types.ts +1 -0
  218. package/{core → upload}/FileValidator.ts +10 -1
  219. package/upload/RestUpload.ts +130 -0
  220. package/{core/components → upload}/UploadComponent.ts +11 -11
  221. package/{core → upload}/UploadManager.ts +3 -3
  222. package/upload/index.ts +23 -7
  223. package/utils/UploadHelper.ts +27 -6
  224. package/utils/cronParser.ts +16 -6
  225. package/.github/workflows/deploy-docs.yml +0 -57
  226. package/core/Components.ts +0 -202
  227. package/core/EntityCache.ts +0 -15
  228. package/core/Query.ts +0 -880
  229. package/docs/README.md +0 -149
  230. package/docs/_coverpage.md +0 -36
  231. package/docs/_sidebar.md +0 -23
  232. package/docs/api/core.md +0 -568
  233. package/docs/api/hooks.md +0 -554
  234. package/docs/api/index.md +0 -222
  235. package/docs/api/query.md +0 -678
  236. package/docs/api/service.md +0 -744
  237. package/docs/core-concepts/archetypes.md +0 -512
  238. package/docs/core-concepts/components.md +0 -498
  239. package/docs/core-concepts/entity.md +0 -314
  240. package/docs/core-concepts/hooks.md +0 -683
  241. package/docs/core-concepts/query.md +0 -588
  242. package/docs/core-concepts/services.md +0 -647
  243. package/docs/examples/code-examples.md +0 -425
  244. package/docs/getting-started.md +0 -337
  245. package/docs/index.html +0 -97
  246. package/tests/bench/insert.bench.ts +0 -60
  247. package/tests/bench/relations.bench.ts +0 -270
  248. package/tests/bench/sorting.bench.ts +0 -416
  249. package/tests/component-hooks-simple.test.ts +0 -117
  250. package/tests/component-hooks.test.ts +0 -1461
  251. package/tests/component.test.ts +0 -339
  252. package/tests/errorHandling.test.ts +0 -155
  253. package/tests/hooks.test.ts +0 -667
  254. package/tests/query-sorting.test.ts +0 -101
  255. package/tests/query.test.ts +0 -81
  256. package/tests/relations.test.ts +0 -170
  257. package/tests/scheduler.test.ts +0 -724
@@ -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 component target
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
- target.constructor.__scheduledTasks.push(taskInfo);
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
@@ -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 { generateGraphQLSchema } from "gql/Generator";
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
- // Disable auto-generated archetype operations to avoid conflicts with manual operations
23
- const { schema } = generateGraphQLSchema(servicesArray, {
22
+
23
+ const result = generateGraphQLSchemaV2(servicesArray, {
24
24
  enableArchetypeOperations: false
25
25
  });
26
- this.schema = schema;
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 "../../types/upload.types";
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 "../../types/upload.types";
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
- return path
108
- .replace(/\.\./g, '')
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
  }