diff --git a/.oxlintrc.json b/.oxlintrc.json index ead58df..c7ca800 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -86,7 +86,8 @@ "unicorn/no-null": "off", "eslint/no-underscore-dangle": "off", "typescript/no-this-alias": "off", - "vitest/require-top-level-describe": "off" + "vitest/require-top-level-describe": "off", + "typescript/no-unnecessary-type-parameters": "off" }, "env": { "builtin": true, diff --git a/package.json b/package.json index ad88f5a..455d4a3 100644 --- a/package.json +++ b/package.json @@ -31,21 +31,6 @@ "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./di": { - "import": "./dist/di.js", - "types": "./dist/di.d.ts" - }, - "./simple-suite": { - "import": "./dist/simple-suite.js", - "types": "./dist/simple-suite.d.ts" - }, - "./package.json": "./package.json" - }, "publishConfig": { "access": "public", "provenance": true diff --git a/skill/example (simple-suite)/AlertDispatch.ts b/skill/example (simple-suite)/AlertDispatch.ts deleted file mode 100644 index aac49a6..0000000 --- a/skill/example (simple-suite)/AlertDispatch.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { BaseArgs } from 'synthkernel/simple-suite'; -import { SimpleBaseModule } from 'synthkernel/simple-suite'; -import CoreLogging from './CoreLogging.ts'; - -export default class AlertDispatch extends SimpleBaseModule { - private readonly logging: CoreLogging; - private readonly unsub: () => void; - declare options: { - minMessageLength: number; - maxMessageLength: number; - }; - - constructor(...args: BaseArgs) { - super(...args); - this.logging = this.container.get(CoreLogging); - this.unsub = this.logging.onOverflow.subscribe((log) => - this.dispatchAlert(`Log overflow: ${JSON.stringify(log)}`), - ); - } - - dispatchAlert = async (message: string): Promise => { - this.logging.log('INFO', `Attempted dispatch: "${message}"`); - const { minMessageLength, maxMessageLength } = this.options; - if (message.length < minMessageLength) { - this.logging.log( - 'ERROR', - `Validation failed: message too short (min: ${minMessageLength})`, - ); - return false; - } - if (message.length > maxMessageLength) { - this.logging.log( - 'ERROR', - `Validation failed: message too long (max: ${maxMessageLength})`, - ); - return false; - } - - await this.connectAlertService(message); - return true; - }; - - private readonly connectAlertService = async (alert: string) => { - this.logging.log('INFO', `Dispatched: "${alert}"`); - // Simulate async connection to alerting service, like an email API - await new Promise((resolve) => setTimeout(resolve, 10)); - }; - - onDispose() { - this.unsub(); - this.logging.log('INFO', 'AlertDispatch disposed'); - } - onStart() { - this.logging.log('INFO', 'AlertDispatch initialized'); - } - augmentation = { dispatchAlert: this.dispatchAlert }; -} diff --git a/skill/example (simple-suite)/CoreLogging.ts b/skill/example (simple-suite)/CoreLogging.ts deleted file mode 100644 index 9e626d9..0000000 --- a/skill/example (simple-suite)/CoreLogging.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { hook } from 'synthkernel'; -import { SimpleBaseModule } from 'synthkernel/simple-suite'; -import type { BaseOptions } from './index.ts'; - -// Helper to enforce hierarchy -const LEVELS = { DEBUG: 0, ERROR: 3, INFO: 1, WARN: 2 } as const; -type Level = keyof typeof LEVELS; - -type LogEntry = { - timestamp: number; - level: string; - message: string; -}; - -export default class CoreLogging extends SimpleBaseModule { - private logs: Array = []; - onOverflow = hook<[LogEntry]>(); // A hook to notify when log overflow occurs for other modules to subscribe to - - log = (level: Level, message: string) => { - const currentLevel = LEVELS[level]; - const minLevel = LEVELS[this.options.logLevel] ?? 0; - if (currentLevel < minLevel) return; // Skip logging if below threshold - const entry: LogEntry = { - level, - message, - timestamp: Date.now(), - }; - const maxLogs = this.options.maxLogs ?? 1000; - if (this.logs.length >= maxLogs) this.onOverflow(this.logs.shift()!); - this.logs.push(entry); - - // Always print if debug mode is forced in base options, otherwise respect level - if (this.options.debug || currentLevel >= minLevel) console.log(`[${level}] ${message}`); - }; - - onStart(): void { - this.log('INFO', 'CoreLogging initialized'); - } - onDispose(): void { - this.log('INFO', 'CoreLogging disposed'); - this.logs = []; - this.onOverflow.dispose(); - } - augmentation = { - log: this.log, - logs: this.logs, - }; - declare options: { - logLevel: Level; - maxLogs?: number; - } & BaseOptions; -} diff --git a/skill/example (simple-suite)/index.ts b/skill/example (simple-suite)/index.ts deleted file mode 100644 index 7923511..0000000 --- a/skill/example (simple-suite)/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AugmentedConstructor } from 'synthkernel'; -import type { Augmentation, Options } from 'synthkernel/simple-suite'; -import { SimpleLoader } from 'synthkernel/simple-suite'; -import AlertDispatch from './AlertDispatch.ts'; -import CoreLogging from './CoreLogging.ts'; - -export type BaseOptions = { - appName: string; - debug?: boolean; -}; - -const allModules = [CoreLogging, AlertDispatch]; -type AllModules = typeof allModules; - -type AllOptions = Options & BaseOptions; -type AllAugmentation = Augmentation; - -class PolisAlert extends SimpleLoader { - constructor(options: AllOptions) { - super(options, allModules); - } -} - -export default PolisAlert as AugmentedConstructor; diff --git a/skill/example (simple-suite)/main.ts b/skill/example (simple-suite)/main.ts deleted file mode 100644 index 934867c..0000000 --- a/skill/example (simple-suite)/main.ts +++ /dev/null @@ -1,29 +0,0 @@ -// oxlint-disable vitest/require-hook -/** - * PolisAlert Consumer Example - * Demonstrates type-safe augmentation and module composition - */ - -import PolisAlert from './index.ts'; - -// Create the loader with orchestrated options from all modules -const app = new PolisAlert({ - appName: 'PolisAlert', - debug: true, - logLevel: 'DEBUG', - maxLogs: 500, - maxMessageLength: 280, - minMessageLength: 1, -}); - -// Type-safe access to augmented methods -app.log('INFO', 'Application started'); - -const success = await app.dispatchAlert('Hello Polis'); -console.log('Dispatch result:', success); - -// Access orchestrated state -console.log('Audit trail:', app.logs); - -// Cleanup -app.dispose(); diff --git a/skill/example/AlertDispatch.ts b/skill/example/AlertDispatch.ts index 424ea70..b513722 100644 --- a/skill/example/AlertDispatch.ts +++ b/skill/example/AlertDispatch.ts @@ -2,37 +2,35 @@ * AlertDispatch: Handles validation and transmission of alerts */ -import type { BaseArgs } from './BaseModule.ts'; -import { BaseModule } from './BaseModule.ts'; -import CoreLogging from './CoreLogging.ts'; +import type { Hook } from 'synthkernel'; +import type { LogEntry, Level } from './CoreLogging.ts'; -export default class AlertDispatch extends BaseModule { - private readonly logging: CoreLogging; +export default class AlertDispatch { private readonly unsub: () => void; - constructor(...args: BaseArgs) { - super(...args); - this.logging = this.container.get(CoreLogging); - this.unsub = this.logging.onOverflow.subscribe((log) => + constructor( + private readonly ctx: { + log: (level: Level, msg: string) => void; + onLogOverflow: Hook<[LogEntry]>; + }, + ) { + this.unsub = ctx.onLogOverflow.subscribe((log) => this.dispatchAlert(`Log overflow: ${JSON.stringify(log)}`), ); } dispatchAlert = async (message: string): Promise => { - this.logging.log('INFO', `Attempted dispatch: "${message}"`); + this.ctx.log('INFO', `Attempted dispatch: "${message}"`); const { minMessageLength, maxMessageLength } = this.options; if (message.length < minMessageLength) { - this.logging.log( + this.ctx.log( 'ERROR', `Validation failed: message too short (min: ${minMessageLength})`, ); return false; } if (message.length > maxMessageLength) { - this.logging.log( - 'ERROR', - `Validation failed: message too long (max: ${maxMessageLength})`, - ); + this.ctx.log('ERROR', `Validation failed: message too long (max: ${maxMessageLength})`); return false; } @@ -41,19 +39,19 @@ export default class AlertDispatch extends BaseModule { }; private readonly connectAlertService = async (alert: string) => { - this.logging.log('INFO', `Dispatched: "${alert}"`); + this.ctx.log('INFO', `Dispatched: "${alert}"`); // Simulate async connection to alerting service, like an email API await new Promise((resolve) => setTimeout(resolve, 10)); }; onDispose() { this.unsub(); - this.logging.log('INFO', 'AlertDispatch disposed'); + this.ctx.log('INFO', 'AlertDispatch disposed'); } onStart() { - this.logging.log('INFO', 'AlertDispatch initialized'); + this.ctx.log('INFO', 'AlertDispatch initialized'); } - augmentation = { dispatchAlert: this.dispatchAlert }; + root = { dispatchAlert: this.dispatchAlert }; declare options: { minMessageLength: number; maxMessageLength: number; diff --git a/skill/example/BaseModule.ts b/skill/example/BaseModule.ts deleted file mode 100644 index 5188777..0000000 --- a/skill/example/BaseModule.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Orchestratable, ModuleInput as MI } from 'synthkernel'; -import type { Container } from 'synthkernel/di'; - -type ModuleInput = MI; -export type BaseArgs = ConstructorParameters; -export type BaseModuleCtor = typeof BaseModule; - -export type Options = Orchestratable; -export type Augmentation = Orchestratable; - -export class BaseModule { - constructor( - protected container: Container, - public options: object, - ) {} - - onStart(): void {} - onDispose(): void {} - readonly augmentation: object = {}; -} diff --git a/skill/example/CoreLogging.ts b/skill/example/CoreLogging.ts index 841e79f..e4524bb 100644 --- a/skill/example/CoreLogging.ts +++ b/skill/example/CoreLogging.ts @@ -4,27 +4,26 @@ import { hook } from 'synthkernel'; import type { BaseOptions } from './index.ts'; -import { BaseModule } from './BaseModule.ts'; // Helper to enforce hierarchy const LEVELS = { DEBUG: 0, ERROR: 3, INFO: 1, WARN: 2 } as const; -type Level = keyof typeof LEVELS; +export type Level = keyof typeof LEVELS; -type LogEntry = { +export type LogEntry = { timestamp: number; level: string; message: string; }; -export default class CoreLogging extends BaseModule { +export default class CoreLogging { private logs: Array = []; - onOverflow = hook<[LogEntry]>(); // A hook to notify when log overflow occurs for other modules to subscribe to + private readonly onOverflow = hook<[LogEntry]>(); // A hook to notify when log overflow occurs for other modules to subscribe to declare options: { logLevel: Level; maxLogs?: number; } & BaseOptions; - log = (level: Level, message: string) => { + private readonly log = (level: Level, message: string) => { const currentLevel = LEVELS[level]; const minLevel = LEVELS[this.options.logLevel] ?? 0; if (currentLevel < minLevel) return; // Skip logging if below threshold @@ -49,8 +48,9 @@ export default class CoreLogging extends BaseModule { this.logs = []; this.onOverflow.dispose(); } - augmentation = { + root = { log: this.log, logs: this.logs, + onLogOverflow: this.onOverflow, }; } diff --git a/skill/example/README.md b/skill/example/README.md deleted file mode 100644 index 295b576..0000000 --- a/skill/example/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# PolisAlert: A SynthKernel Educational Demo - -**PolisAlert** is a minimal, type-safe notification pipeline designed to demonstrate the **SynthKernel** architecture in action. It is not a production-ready alerting system, but a reference implementation showing how to structure a modular monolith where modules self-register capabilities, orchestrate types, and communicate via dependency injection without tight coupling. - -## Architecture Overview - -This project strictly follows the **SynthKernel** file system conventions and design philosophy: - -- **Loader (`index.ts`)**: Acts solely as a lifecycle manager and type facade. It contains **no business logic**. -- **Modules (`CoreLogging.ts`, `AlertDispatch.ts`)**: Encapsulate all functionality. They define their own options, augment the loader with new APIs, and wire dependencies via the shared container. -- **Type Orchestration**: The public API of the application is automatically constructed by the union of all loaded modules. If a module is not loaded, its methods do not exist on the Loader type. - -### Module Tree - -```text -PolisAlert Loader -├── CoreLogging -│ ├── Role: Infrastructure (Audit Trail) -│ ├── Augments: .log(), .logs -│ └── Options: logLevel, maxLogs -└── AlertDispatch - ├── Role: Alert Function (Validation & Dispatch) - ├── Augments: .dispatchAlert() - ├── Dependencies: CoreLogging (via DI) - └── Options: minMessageLength, maxMessageLength -``` - -## Key Concepts Demonstrated - -### Loader Augmentation - -- `CoreLogging` injects `log()` and `logs` into the Loader instance. -- `AlertDispatch` injects `dispatchAlert()`. -- **Result**: The consumer interacts only with the Loader, unaware of the underlying module complexity. - -### Type Safety & Orchestration - -The TypeScript compiler knows exactly what methods are available based on which modules are registered in `index.ts`. - -- **Scenario**: If you remove `AlertDispatch` from the `allModules` array, calling `loader.dispatchAlert()` will immediately throw a **compile-time error**. -- **Mechanism**: Achieved via `Orchestratable` utility types and intersection of module augmentation interfaces. - -### Dependency Injection - -Modules communicate indirectly through the Loader's DI container. - -For example, `AlertDispatch` does not import `CoreLogging`'s class logic directly. Instead, it requests an instance from the container (`this.container.get(CoreLogging)`). - -### Lifecycle Hooks - -Modules subscribe to global events (`onStart`, `onDispose`) defined by the Loader. Both modules log their initialization status automatically when the app starts, demonstrating clean separation of lifecycle management from business logic. - -## File Structure - -```bash -. # the example folder -├── index.ts # The Loader: Defines base options, registers modules, handles lifecycle -├── BaseModule.ts # Abstract base class for all modules (boilerplate reduction) -├── types.ts # Core SynthKernel type utilities (Orchestratable, UnionToIntersection) -├── utilities.ts # Hook system implementation (makeHook) -├── CoreLogging.ts # Module: Handles logging state and audit trail -├── AlertDispatch.ts # Module: Handles validation and message dispatching -└── main.ts # Consumer: Example usage of PolisAlert -``` diff --git a/skill/example/index.ts b/skill/example/index.ts index 620e583..08f85e1 100644 --- a/skill/example/index.ts +++ b/skill/example/index.ts @@ -1,18 +1,6 @@ -/** - * PolisAlert Loader - * Dependency injection enabled: Yes (@needle-di/core) - * Augmentation enabled: Yes - * Lifecycle hooks: - * onStart: Fired when all modules are loaded and initialized - * onDispose: Fired when the application is disposed - * Orchestrations: - * options: Module-contributed configuration options - * augmentation: Module-contributed methods and properties exposed to consumers - */ - -import type { AugmentedConstructor } from 'synthkernel'; -import { Container } from 'synthkernel/di'; -import type { Augmentation, BaseModule, Options, BaseModuleCtor } from './BaseModule.ts'; +import type { MergeSingleKey, Context } from 'synthkernel'; +import { createContext } from 'synthkernel'; +import type { Level, LogEntry } from './CoreLogging.ts'; import AlertDispatch from './AlertDispatch.ts'; import CoreLogging from './CoreLogging.ts'; @@ -21,49 +9,33 @@ export type BaseOptions = { debug?: boolean; }; -const allModules = [CoreLogging, AlertDispatch]; +const allModules = [CoreLogging, AlertDispatch] as const; type AllModules = typeof allModules; -type AllOptions = Options & BaseOptions; -type AllAugmentation = Augmentation; - class PolisAlert { - private readonly loadedModules: Array = []; - container: Container; - - private readonly augment = (aug: object) => { - const descriptors = Object.getOwnPropertyDescriptors(aug); - Object.defineProperties(this, descriptors); - }; + private ctx?: Context; - constructor(public options: AllOptions) { - this.container = new Container(); + dispatchAlert: (message: string) => Promise; + log: (level: Level, message: string) => void; + logs: Array; - const bind = (Module: BaseModuleCtor) => { - this.container.bind({ - provide: Module, - useFactory: () => { - const module = new Module(this.container, this.options); - this.loadedModules.push(module); - return module; - }, - }); - }; - - allModules.forEach(bind); - allModules.forEach((Module: BaseModuleCtor) => this.container.get(Module)); - - this.loadedModules.forEach((module) => { - this.augment(module.augmentation); - module.onStart(); + constructor(public options: MergeSingleKey) { + this.ctx = createContext(allModules, { + assign: { options }, + mergeKeys: ['options', 'root'], }); + for (const ctor of allModules) this.ctx.__getModule__(ctor).onStart(); + + // Augmentation + this.dispatchAlert = this.ctx.dispatchAlert; + this.log = this.ctx.log; + this.logs = this.ctx.logs; } dispose = () => { - this.loadedModules.reverse(); - while (this.loadedModules.length) this.loadedModules.pop()?.onDispose(); - this.container.unbindAll(); + for (const ctor of allModules.toReversed()) this.ctx?.__getModule__(ctor).onDispose(); + this.ctx = undefined; }; } -export default PolisAlert as AugmentedConstructor; +export default PolisAlert; diff --git a/skill/example/main.ts b/skill/example/main.ts index 934867c..f0be376 100644 --- a/skill/example/main.ts +++ b/skill/example/main.ts @@ -9,7 +9,6 @@ import PolisAlert from './index.ts'; // Create the loader with orchestrated options from all modules const app = new PolisAlert({ appName: 'PolisAlert', - debug: true, logLevel: 'DEBUG', maxLogs: 500, maxMessageLength: 280, diff --git a/src/SimpleBaseModule.ts b/src/SimpleBaseModule.ts deleted file mode 100644 index 5732d07..0000000 --- a/src/SimpleBaseModule.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Orchestratable, ModuleInput as MI } from 'synthkernel'; -import type { Container } from 'synthkernel/di'; - -type ModuleInput = MI; -export type BaseArgs = ConstructorParameters; -export type SinpleBaseModuleCtor = typeof SimpleBaseModule; - -export type Options = Orchestratable; -export type Augmentation = Orchestratable; - -export class SimpleBaseModule { - constructor( - protected container: Container, - public options: object, - ) {} - - onStart(): void {} - onDispose(): void {} - readonly augmentation: object = {}; -} diff --git a/src/SimpleLoader.ts b/src/SimpleLoader.ts deleted file mode 100644 index aa3e9d1..0000000 --- a/src/SimpleLoader.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Container } from '@needle-di/core'; -import type { SinpleBaseModuleCtor, SimpleBaseModule } from './SimpleBaseModule'; - -export default class SimpleLoader { - private readonly loadedModules: Array = []; - container: Container; - - private readonly augment = (aug: object) => { - const descriptors = Object.getOwnPropertyDescriptors(aug); - Object.defineProperties(this, descriptors); - }; - - constructor( - public options: A, - allModules: Array, - ) { - this.container = new Container(); - - const bind = (Module: SinpleBaseModuleCtor) => { - this.container.bind({ - provide: Module, - useFactory: () => { - const module = new Module(this.container, this.options); - this.loadedModules.push(module); - return module; - }, - }); - }; - - allModules.forEach(bind); - allModules.forEach((Module: SinpleBaseModuleCtor) => this.container.get(Module)); - - this.loadedModules.forEach((module) => { - this.augment(module.augmentation); - module.onStart?.(); - }); - } - - dispose = () => { - this.loadedModules.reverse(); - while (this.loadedModules.length) this.loadedModules.pop()?.onDispose?.(); - this.container.unbindAll(); - }; -} diff --git a/src/di.ts b/src/di.ts deleted file mode 100644 index 07dc2cf..0000000 --- a/src/di.ts +++ /dev/null @@ -1,22 +0,0 @@ -export { - Container, - bootstrap, - bootstrapAsync, - inject, - injectAsync, - injectable, - InjectionToken, -} from '@needle-di/core'; -export type { - AsyncFactoryProvider, - AsyncProvider, - ClassProvider, - ConstructorProvider, - ExistingProvider, - FactoryProvider, - Provider, - SyncFactoryProvider, - SyncProvider, - Token, - ValueProvider, -} from '@needle-di/core'; diff --git a/src/index.ts b/src/index.ts index 7015589..bc93908 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ +export { default as createContext } from './types'; export type * from './types'; export * from './reactive'; diff --git a/src/simple-suite.ts b/src/simple-suite.ts deleted file mode 100644 index bdd3222..0000000 --- a/src/simple-suite.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as SimpleLoader } from './SimpleLoader'; -export * from './SimpleBaseModule'; diff --git a/src/types.ts b/src/types.ts index 25c72c9..c85c4ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,27 +3,238 @@ type GeneralObject = object; type GeneralConstructor = | (new (...args: Array) => General) | (abstract new (...args: Array) => General); - -type UnionToIntersection = (U extends General ? (k: U) => void : never) extends ( - k: infer I, -) => void - ? I - : never; +type ModuleConstructor = new (context: C) => General; type GeneralModuleInput = ReadonlyArray | ReadonlyArray; type Instances = - T extends ReadonlyArray ? InstanceType : T[number]; + T extends ReadonlyArray ? { [K in keyof T]: InstanceType } : T; export type ModuleInput = | ReadonlyArray | ReadonlyArray>; -export type Orchestratable< - T extends GeneralModuleInput, - K extends keyof Instances, -> = UnionToIntersection[K]>; +type IsPlainObject = T extends object + ? T extends Function | Date | RegExp | Array | Map | Set + ? false + : true + : false; + +type ShallowMergeValue = + IsPlainObject extends true ? (IsPlainObject extends true ? Omit & B : B) : B; + +type ShallowMergeObjects = { + [K in keyof A | keyof B]: K extends keyof B + ? K extends keyof A + ? ShallowMergeValue + : B[K] + : K extends keyof A + ? A[K] + : never; +}; + +type RootValue = R extends keyof T + ? Extract + : {}; + +type PickMerged = ShallowMergeObjects< + Pick>, + RootValue +>; + +type PickEach, K extends keyof T[number], R extends PropertyKey> = { + [I in keyof T]: PickMerged, R>; +}; + +type MergeObjects> = T extends readonly [ + infer First extends object, + ...infer Rest extends ReadonlyArray, +] + ? Rest['length'] extends 0 + ? First + : ShallowMergeObjects> + : {}; + +type MergeValues> = T extends readonly [infer First, ...infer Rest] + ? Rest extends ReadonlyArray + ? Rest['length'] extends 0 + ? First + : ShallowMergeValue> + : never + : {}; + +type MergeResult< + M extends GeneralModuleInput, + K extends keyof Instances[number], + Pr extends object, + Po extends object, + R extends string, +> = MergeObjects<[Pr, ...PickEach, K, R>, Po]>; + +export type Context< + M extends ReadonlyArray>, + K extends keyof Instances[number], + Pr extends object = {}, + Po extends object = {}, + R extends string = 'root', +> = MergeResult & { + __modules__: WeakMap>; + __getModule__: (ctor: C) => InstanceType; + __addModule__: >>( + newModule: N, + ) => Context<[...M, N], K, Pr, Po, R>; +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function mergeShallow(target: Record, source: unknown) { + if (!isPlainObject(source)) return target; + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + if (sourceValue === undefined) continue; + const targetValue = target[key]; + if (isPlainObject(targetValue) && isPlainObject(sourceValue)) { + target[key] = { ...targetValue, ...sourceValue }; + continue; + } + target[key] = isPlainObject(sourceValue) ? { ...sourceValue } : sourceValue; + } + return target; +} + +/** + * Creates shared context from ordered modules. + * + * Merge order: `preMerge` -> module keys -> `postMerge` -> `assign`. + * Object values merge shallowly. `rootKey` flattens matching module value into + * context root instead of assigning `context[rootKey]`. + * + * After final merge, `injectKeys` are written back to every instance. When + * omitted, `mergeKeys` are reused. Injecting `rootKey` writes whole context. + * + * Returned context also exposes `__modules__`, `__getModule__`, and + * `__addModule__` as non-enumerable helpers. + * + * @typeParam R Root merge key. Defaults to `root`. + * @typeParam Po Object merged after module output. + * @typeParam Pr Object merged before module construction. + * @typeParam M Ordered module constructor list. + * @typeParam K Keys copied from modules into context. + * @typeParam I Keys injected back into module instances. + * @param classes Module constructors instantiated in order. + * @param options Context build options. + * @param options.rootKey Key flattened into context root. Defaults to `root`. + * @param options.preMerge Values merged before module construction. + * @param options.postMerge Values merged after module merges. + * @param options.assign Final values assigned after all other merges. + * @param options.mergeKeys Instance keys merged into context. + * @param options.injectKeys Instance keys updated from final context. Defaults to + * `mergeKeys`. + * @returns Context object containing merged values plus module helper methods. + */ +export default function createContext< + Po extends object, + Pr extends object, + M extends ReadonlyArray>>, + K extends keyof Instances[number], + R extends string = 'root', + I extends K = K, +>( + classes: M, + options: { + rootKey?: R; + preMerge?: Pr; + postMerge?: Po; + assign?: Partial>; + mergeKeys: ReadonlyArray; + injectKeys?: ReadonlyArray; + }, +): Context { + const context: Record = {}; + const rootKey = (options.rootKey ?? 'root') as R; + const mergeKeys = options.mergeKeys as ReadonlyArray; + const injectKeys = (options.injectKeys ?? options.mergeKeys) as ReadonlyArray; + const instances: Array> = []; + const modules = new WeakMap>(); + + const mergeInstance = (instance: Record) => { + for (const key of mergeKeys) { + const value = instance[key]; + if (value === undefined) continue; + if (key === rootKey) { + if (isPlainObject(value)) mergeShallow(context, value); + continue; + } + + const current = context[key]; + context[key] = + isPlainObject(current) && isPlainObject(value) + ? { ...current, ...value } + : isPlainObject(value) + ? { ...value } + : value; + } + }; + + const injectContext = () => { + for (const instance of instances) + for (const key of injectKeys) instance[key] = key === rootKey ? context : context[key]; + }; + + Object.defineProperties(context, { + __addModule__: { + enumerable: false, + value: >>( + newModule: N, + ) => { + const instance = new newModule(context as Context<[...M, N], K, Pr, Po, R>); + const record = instance as Record; + modules.set(newModule, instance); + instances.push(record); + mergeInstance(record); + mergeShallow(context, options.postMerge); + mergeShallow(context, options.assign); + injectContext(); + return context as Context<[...M, N], K, Pr, Po, R>; + }, + }, + __getModule__: { + enumerable: false, + value: (ctor: C) => { + const instance = modules.get(ctor); + if (!instance) throw new Error('Module not found in context'); + return instance as InstanceType; + }, + }, + __modules__: { + enumerable: false, + value: modules, + }, + }); + + mergeShallow(context, options.preMerge); + + for (const Class of classes) { + const instance = new Class(context as Context); + const record = instance as Record; + modules.set(Class, instance); + instances.push(record); + mergeInstance(record); + } + + mergeShallow(context, options.postMerge); + mergeShallow(context, options.assign); + injectContext(); + + return context as Context; +} -export type AugmentedConstructor = new ( - ...args: ConstructorParameters -) => InstanceType & A; +type ExtractKeyEach, K extends keyof T[number]> = { + [I in keyof T]: K extends keyof T[I] ? T[I][K] : never; +}; +export type MergeSingleKey< + M extends GeneralModuleInput, + K extends keyof Instances[number], +> = MergeValues, K>>; diff --git a/tsdown.config.ts b/tsdown.config.ts index 96ae73a..7cccdd3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ dts: true, - entry: ['src/index.ts', 'src/simple-suite.ts', 'src/di.ts'], + entry: ['src/index.ts'], minify: true, outExtensions: () => ({ dts: '.d.ts',