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
@@ -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
  }
@@ -2,7 +2,7 @@ import {
2
2
  GraphQLScalar,
3
3
  type GraphQLObject,
4
4
  type GraphQLField
5
- } from "gql/types"
5
+ } from "../../../gql/types"
6
6
 
7
7
  export interface GQLObjectMetaData {
8
8
  name: string;
@@ -1,5 +1,46 @@
1
1
  import "reflect-metadata";
2
- export {getMetadataStorage} from "./getMetadataStorage";
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
- this.archetypes.push(metadata);
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,3 @@
1
+ export { securityHeaders, type SecurityHeadersOptions } from './SecurityHeaders';
2
+ export { requestId, getRequestId, requestStore } from './RequestId';
3
+ export { accessLog, type AccessLogOptions } from './AccessLog';
@@ -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
+ }