@kronos-ts/app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +228 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +519 -0
- package/dist/app.js.map +1 -0
- package/dist/components.d.ts +28 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +14 -0
- package/dist/components.js.map +1 -0
- package/dist/decorator.d.ts +54 -0
- package/dist/decorator.d.ts.map +1 -0
- package/dist/decorator.js +36 -0
- package/dist/decorator.js.map +1 -0
- package/dist/defaults-handles.d.ts +25 -0
- package/dist/defaults-handles.d.ts.map +1 -0
- package/dist/defaults-handles.js +25 -0
- package/dist/defaults-handles.js.map +1 -0
- package/dist/defaults.d.ts +10 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +49 -0
- package/dist/defaults.js.map +1 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +53 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/kronos.d.ts +51 -0
- package/dist/kronos.d.ts.map +1 -0
- package/dist/kronos.js +74 -0
- package/dist/kronos.js.map +1 -0
- package/dist/lifecycle.d.ts +27 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +7 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/resolved.d.ts +18 -0
- package/dist/resolved.d.ts.map +1 -0
- package/dist/resolved.js +43 -0
- package/dist/resolved.js.map +1 -0
- package/dist/slot-registry.d.ts +35 -0
- package/dist/slot-registry.d.ts.map +1 -0
- package/dist/slot-registry.js +50 -0
- package/dist/slot-registry.js.map +1 -0
- package/dist/warnings.d.ts +19 -0
- package/dist/warnings.d.ts.map +1 -0
- package/dist/warnings.js +14 -0
- package/dist/warnings.js.map +1 -0
- package/package.json +59 -0
- package/src/app.ts +795 -0
- package/src/components.ts +48 -0
- package/src/decorator.ts +88 -0
- package/src/defaults-handles.ts +29 -0
- package/src/defaults.ts +64 -0
- package/src/errors.ts +62 -0
- package/src/index.ts +38 -0
- package/src/kronos.ts +117 -0
- package/src/lifecycle.ts +33 -0
- package/src/resolved.ts +54 -0
- package/src/slot-registry.ts +89 -0
- package/src/warnings.ts +32 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EventStore, SnapshotStore, TagResolver } from "@kronos-ts/eventsourcing"
|
|
2
|
+
import type {
|
|
3
|
+
CommandBus,
|
|
4
|
+
QueryBus,
|
|
5
|
+
EventBus,
|
|
6
|
+
UoWRunner,
|
|
7
|
+
TokenStore,
|
|
8
|
+
TransactionManager,
|
|
9
|
+
} from "@kronos-ts/messaging"
|
|
10
|
+
import type { Serializer } from "@kronos-ts/common"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fixed slot interface. SLT-01: enumerates ALL 10 framework slots.
|
|
14
|
+
* No declaration merging, no string tokens — closed contract.
|
|
15
|
+
*
|
|
16
|
+
* Plan 09-01 (D-84): tokenStore + transactionManager added so persistence
|
|
17
|
+
* extensions (KronosDB, etc.) replace typed slots instead of routing through
|
|
18
|
+
* the deleted configurer's componentRegistry.
|
|
19
|
+
*/
|
|
20
|
+
export interface KronosComponents {
|
|
21
|
+
eventStore: EventStore
|
|
22
|
+
snapshotStore: SnapshotStore
|
|
23
|
+
commandBus: CommandBus
|
|
24
|
+
queryBus: QueryBus
|
|
25
|
+
eventBus: EventBus
|
|
26
|
+
serializer: Serializer
|
|
27
|
+
unitOfWorkFactory: UoWRunner
|
|
28
|
+
tagResolver: TagResolver
|
|
29
|
+
tokenStore: TokenStore
|
|
30
|
+
transactionManager: TransactionManager
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Type-level: keyof KronosComponents — for verb signatures. */
|
|
34
|
+
export type SlotName = keyof KronosComponents
|
|
35
|
+
|
|
36
|
+
/** Sentinel listing every slot name; used by .start() to iterate slots and emit startup warnings. Order is stable for deterministic warning emission. */
|
|
37
|
+
export const ALL_SLOTS: readonly SlotName[] = [
|
|
38
|
+
"eventStore",
|
|
39
|
+
"snapshotStore",
|
|
40
|
+
"commandBus",
|
|
41
|
+
"queryBus",
|
|
42
|
+
"eventBus",
|
|
43
|
+
"serializer",
|
|
44
|
+
"unitOfWorkFactory",
|
|
45
|
+
"tagResolver",
|
|
46
|
+
"tokenStore",
|
|
47
|
+
"transactionManager",
|
|
48
|
+
] as const
|
package/src/decorator.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Resolved } from "./resolved.js"
|
|
2
|
+
import type { KronosComponents, SlotName } from "./components.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Slot-typed handle returned by `app.decorate()`. The `__slot` brand prevents
|
|
6
|
+
* cross-slot removal at compile time (DEC-03). `__id` is the runtime identity
|
|
7
|
+
* key used by `removeDecorator()` to locate the registration.
|
|
8
|
+
*
|
|
9
|
+
* Framework defaults expose pre-allocated handle constants on `Defaults` so
|
|
10
|
+
* users can write `app.removeDecorator(Defaults.commandBus.intercepting)`.
|
|
11
|
+
*/
|
|
12
|
+
export interface DecoratorHandle<K extends SlotName> {
|
|
13
|
+
readonly __slot: K
|
|
14
|
+
readonly __id: symbol
|
|
15
|
+
readonly __name: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A decorator factory wraps the slot's resolved value. Polymorphic over the
|
|
20
|
+
* slot interface (DEC-04) — receives `KronosComponents[K]` and returns
|
|
21
|
+
* `KronosComponents[K]`. The `resolved` proxy is the same lazy proxy passed
|
|
22
|
+
* to slot factories (Phase 5 D-52); decorators may pull sibling slots through
|
|
23
|
+
* it (cycle detection covers this).
|
|
24
|
+
*/
|
|
25
|
+
export type DecoratorFactory<K extends SlotName> = (
|
|
26
|
+
inner: KronosComponents[K],
|
|
27
|
+
resolved: Resolved,
|
|
28
|
+
) => KronosComponents[K]
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Internal accumulator entry. Stored on `AppState.decoratorRegistrations` in
|
|
32
|
+
* registration order — pipeline order at `.start()` is left-to-right
|
|
33
|
+
* (last registered = outermost wrap, per D-61 / DESIGN.md §8).
|
|
34
|
+
*
|
|
35
|
+
* `frameworkDefault` distinguishes framework-registered defaults (Plan 02)
|
|
36
|
+
* from user-registered decorators (D-62 — user decorators wrap OUTSIDE
|
|
37
|
+
* framework defaults). The `.start()` decoration step partitions on this
|
|
38
|
+
* field and applies framework defaults first (innermost), then user
|
|
39
|
+
* decorators (outermost).
|
|
40
|
+
*/
|
|
41
|
+
export interface DecoratorEntry<K extends SlotName = SlotName> {
|
|
42
|
+
readonly handle: DecoratorHandle<K>
|
|
43
|
+
readonly factory: DecoratorFactory<K>
|
|
44
|
+
readonly frameworkDefault: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @internal — used by `Defaults` and by `kronos()` bootstrap (Plan 02) to
|
|
49
|
+
* mint pre-allocated framework-default handles with stable identity.
|
|
50
|
+
*/
|
|
51
|
+
export function makeFrameworkHandle<K extends SlotName>(
|
|
52
|
+
slot: K,
|
|
53
|
+
name: string,
|
|
54
|
+
): DecoratorHandle<K> {
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
__slot: slot,
|
|
57
|
+
__id: Symbol(`${slot}.${name}`),
|
|
58
|
+
__name: name,
|
|
59
|
+
}) as DecoratorHandle<K>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply all decorator registrations for a given slot in two passes:
|
|
64
|
+
* 1. Framework defaults first (innermost, handler-adjacent)
|
|
65
|
+
* 2. User decorators after (outer; last .decorate() = outermost wrap)
|
|
66
|
+
*
|
|
67
|
+
* Both passes iterate `registrations` in registration order so a
|
|
68
|
+
* left-to-right reduce composes correctly (each factory wraps the current value).
|
|
69
|
+
*/
|
|
70
|
+
export function applyDecorators<K extends SlotName>(
|
|
71
|
+
slot: K,
|
|
72
|
+
base: KronosComponents[K],
|
|
73
|
+
registrations: ReadonlyArray<DecoratorEntry>,
|
|
74
|
+
resolved: Resolved,
|
|
75
|
+
): KronosComponents[K] {
|
|
76
|
+
let current = base
|
|
77
|
+
// Pass 1: framework defaults (innermost)
|
|
78
|
+
for (const reg of registrations) {
|
|
79
|
+
if (reg.handle.__slot !== slot || !reg.frameworkDefault) continue
|
|
80
|
+
current = (reg.factory as unknown as DecoratorFactory<K>)(current, resolved)
|
|
81
|
+
}
|
|
82
|
+
// Pass 2: user decorators (outer)
|
|
83
|
+
for (const reg of registrations) {
|
|
84
|
+
if (reg.handle.__slot !== slot || reg.frameworkDefault) continue
|
|
85
|
+
current = (reg.factory as unknown as DecoratorFactory<K>)(current, resolved)
|
|
86
|
+
}
|
|
87
|
+
return current
|
|
88
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { makeFrameworkHandle, type DecoratorHandle } from "./decorator.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Global static module export — frozen handle identities for framework-default
|
|
5
|
+
* decorators (D-54). Per-app decorator state lives on `AppState`; `Defaults`
|
|
6
|
+
* only carries handle *identities*.
|
|
7
|
+
*
|
|
8
|
+
* Plan 02 wires the actual factories in `kronos()` bootstrap, keyed by these
|
|
9
|
+
* handles. Plan 01 ships the handle constants only — having them in place
|
|
10
|
+
* lets users write `app.removeDecorator(Defaults.commandBus.intercepting)`
|
|
11
|
+
* even before Plan 02 registers the factories (the call will throw
|
|
12
|
+
* `UnknownDecoratorHandleError` until Plan 02 lands, which is correct
|
|
13
|
+
* behavior for now and ratchets to the real removal once Plan 02 ships).
|
|
14
|
+
*/
|
|
15
|
+
export const Defaults = Object.freeze({
|
|
16
|
+
commandBus: Object.freeze({
|
|
17
|
+
intercepting: makeFrameworkHandle("commandBus", "intercepting"),
|
|
18
|
+
}),
|
|
19
|
+
queryBus: Object.freeze({
|
|
20
|
+
intercepting: makeFrameworkHandle("queryBus", "intercepting"),
|
|
21
|
+
}),
|
|
22
|
+
eventBus: Object.freeze({
|
|
23
|
+
intercepting: makeFrameworkHandle("eventBus", "intercepting"),
|
|
24
|
+
}),
|
|
25
|
+
}) as {
|
|
26
|
+
readonly commandBus: { readonly intercepting: DecoratorHandle<"commandBus"> }
|
|
27
|
+
readonly queryBus: { readonly intercepting: DecoratorHandle<"queryBus"> }
|
|
28
|
+
readonly eventBus: { readonly intercepting: DecoratorHandle<"eventBus"> }
|
|
29
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { App } from "./app.js"
|
|
2
|
+
import type { EventBus } from "@kronos-ts/messaging"
|
|
3
|
+
import {
|
|
4
|
+
createInMemoryEventStore,
|
|
5
|
+
createInMemorySnapshotStore,
|
|
6
|
+
descriptorBasedTagResolver,
|
|
7
|
+
} from "@kronos-ts/eventsourcing"
|
|
8
|
+
import {
|
|
9
|
+
createSimpleCommandBus,
|
|
10
|
+
createSimpleQueryBus,
|
|
11
|
+
jsonSerializer,
|
|
12
|
+
runInNewUoW,
|
|
13
|
+
createInMemoryTokenStore,
|
|
14
|
+
noTransactionManager,
|
|
15
|
+
} from "@kronos-ts/messaging"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register the 8 in-memory defaults (SLT-04, D-51).
|
|
19
|
+
* Slots flagged inMemory:true emit a startup warning at .start() unless the slot
|
|
20
|
+
* was overridden via .set/.forceSet (or another .setDefault won — but setDefault
|
|
21
|
+
* is ifAbsent, so only the FIRST setDefault wins; user setDefault calls on already-defaulted
|
|
22
|
+
* slots are no-ops, which is the intended SLT-02 semantics).
|
|
23
|
+
*/
|
|
24
|
+
export function registerInMemoryDefaults(app: App): void {
|
|
25
|
+
// Durability-flagged defaults (SLT-04 warning fires unless overridden):
|
|
26
|
+
app.setDefault("eventStore", () => createInMemoryEventStore(), {
|
|
27
|
+
inMemory: true,
|
|
28
|
+
warning: "[kronos] eventStore: in-memory — not durable, configure an extension for production",
|
|
29
|
+
})
|
|
30
|
+
app.setDefault("snapshotStore", () => createInMemorySnapshotStore(), {
|
|
31
|
+
inMemory: true,
|
|
32
|
+
warning: "[kronos] snapshotStore: in-memory — not durable, configure an extension for production",
|
|
33
|
+
})
|
|
34
|
+
app.setDefault("commandBus", () => createSimpleCommandBus(), {
|
|
35
|
+
inMemory: true,
|
|
36
|
+
warning: "[kronos] commandBus: in-memory — single-process only, configure an extension for distribution",
|
|
37
|
+
})
|
|
38
|
+
app.setDefault("queryBus", () => createSimpleQueryBus(), {
|
|
39
|
+
inMemory: true,
|
|
40
|
+
warning: "[kronos] queryBus: in-memory — single-process only, configure an extension for distribution",
|
|
41
|
+
})
|
|
42
|
+
// EventBus default: in ES setups EventStore IS the EventBus (configurer mirrors this at line 796)
|
|
43
|
+
app.setDefault("eventBus", ({ eventStore }) => eventStore as unknown as EventBus, {
|
|
44
|
+
inMemory: true,
|
|
45
|
+
warning: "[kronos] eventBus: in-memory — single-process only",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Stateless / non-durability-implied defaults — NO inMemory flag, no warning emission:
|
|
49
|
+
app.setDefault("serializer", () => jsonSerializer())
|
|
50
|
+
app.setDefault("unitOfWorkFactory", () => runInNewUoW)
|
|
51
|
+
app.setDefault("tagResolver", () => descriptorBasedTagResolver())
|
|
52
|
+
|
|
53
|
+
// Plan 09-01 (D-84): typed slots for token persistence + transactional wrapping.
|
|
54
|
+
// Both default to in-memory — extensions (KronosDB, Drizzle/Knex/Kysely token stores,
|
|
55
|
+
// user-supplied TransactionManagers) override via app.set('tokenStore', ...) etc.
|
|
56
|
+
app.setDefault("tokenStore", () => createInMemoryTokenStore(), {
|
|
57
|
+
inMemory: true,
|
|
58
|
+
warning: "[kronos] tokenStore: in-memory — not durable, configure a persistence extension for production",
|
|
59
|
+
})
|
|
60
|
+
app.setDefault("transactionManager", () => noTransactionManager(), {
|
|
61
|
+
inMemory: true,
|
|
62
|
+
warning: "[kronos] transactionManager: in-memory — pass-through, configure a transactional extension for production",
|
|
63
|
+
})
|
|
64
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SlotName } from "./components.js"
|
|
2
|
+
import type { DecoratorHandle } from "./decorator.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when buildResolved detects a cycle: a factory's getter on Resolved re-enters
|
|
6
|
+
* a slot that is currently being resolved. (D-52 cycle-detection contract.)
|
|
7
|
+
*/
|
|
8
|
+
export class CircularSlotDependencyError extends Error {
|
|
9
|
+
readonly slot: SlotName
|
|
10
|
+
readonly chain: readonly SlotName[]
|
|
11
|
+
constructor(slot: SlotName, chain: readonly SlotName[]) {
|
|
12
|
+
super(`[kronos] Circular slot dependency: "${slot}" via [${chain.join(" → ")}]`)
|
|
13
|
+
this.name = "CircularSlotDependencyError"
|
|
14
|
+
this.slot = slot
|
|
15
|
+
this.chain = chain
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Thrown when a Resolved getter touches a slot that has no registered factory. */
|
|
20
|
+
export class SlotNotRegisteredError extends Error {
|
|
21
|
+
readonly slot: SlotName
|
|
22
|
+
constructor(slot: SlotName) {
|
|
23
|
+
super(`[kronos] Slot "${slot}" has no registered factory.`)
|
|
24
|
+
this.name = "SlotNotRegisteredError"
|
|
25
|
+
this.slot = slot
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Thrown when `app.commandGateway` or `app.queryGateway` is accessed before
|
|
31
|
+
* the `register` lifecycle stage completes during `.start()`. Available inside
|
|
32
|
+
* `onStart('warmup'|'register'|'processors'|'serve', fn)` hooks AFTER register
|
|
33
|
+
* completes, and after `.start()` resolves. (Plan 08-01.)
|
|
34
|
+
*/
|
|
35
|
+
export class AppNotStartedError extends Error {
|
|
36
|
+
constructor(accessor: string) {
|
|
37
|
+
super(
|
|
38
|
+
`[kronos] App not started: ${accessor} is only accessible after register-stage completes during .start().`,
|
|
39
|
+
)
|
|
40
|
+
this.name = "AppNotStartedError"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Thrown by `app.removeDecorator(handle)` when the handle is not found in the
|
|
46
|
+
* app's registration list (D-59). Catches typos like
|
|
47
|
+
* `Defaults.commandBus.interceptingg` and removal of a handle that was never
|
|
48
|
+
* registered (e.g., framework default whose factory hasn't been wired yet).
|
|
49
|
+
*/
|
|
50
|
+
export class UnknownDecoratorHandleError extends Error {
|
|
51
|
+
readonly slot: SlotName
|
|
52
|
+
readonly handleName: string
|
|
53
|
+
constructor(handle: DecoratorHandle<SlotName>) {
|
|
54
|
+
super(
|
|
55
|
+
`[kronos] Unknown decorator handle "${handle.__name}" for slot "${handle.__slot}". ` +
|
|
56
|
+
`Either it was never registered or it was already removed.`,
|
|
57
|
+
)
|
|
58
|
+
this.name = "UnknownDecoratorHandleError"
|
|
59
|
+
this.slot = handle.__slot
|
|
60
|
+
this.handleName = handle.__name
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type { KronosComponents, SlotName } from "./components.js"
|
|
2
|
+
export { ALL_SLOTS } from "./components.js"
|
|
3
|
+
export { CircularSlotDependencyError, SlotNotRegisteredError } from "./errors.js"
|
|
4
|
+
export { SlotRegistry, type SlotMeta, type SlotEntry, type SlotFactory } from "./slot-registry.js"
|
|
5
|
+
export { buildResolved, type Resolved } from "./resolved.js"
|
|
6
|
+
export {
|
|
7
|
+
createWarningChannel,
|
|
8
|
+
type WarningLogger,
|
|
9
|
+
type WarningChannelOptions,
|
|
10
|
+
type WarningChannel,
|
|
11
|
+
} from "./warnings.js"
|
|
12
|
+
export {
|
|
13
|
+
AppImpl,
|
|
14
|
+
AppAlreadyStartedError,
|
|
15
|
+
type App,
|
|
16
|
+
type RunningApp,
|
|
17
|
+
type Extension,
|
|
18
|
+
type AppState,
|
|
19
|
+
type AppImplOptions,
|
|
20
|
+
type KronosIdentity,
|
|
21
|
+
} from "./app.js"
|
|
22
|
+
export { registerInMemoryDefaults } from "./defaults.js"
|
|
23
|
+
export { kronos, type KronosPartialConfig } from "./kronos.js"
|
|
24
|
+
export {
|
|
25
|
+
type DecoratorHandle,
|
|
26
|
+
type DecoratorFactory,
|
|
27
|
+
type DecoratorEntry,
|
|
28
|
+
applyDecorators,
|
|
29
|
+
} from "./decorator.js"
|
|
30
|
+
export { Defaults } from "./defaults-handles.js"
|
|
31
|
+
export { UnknownDecoratorHandleError, AppNotStartedError } from "./errors.js"
|
|
32
|
+
export type { LifecycleStage, LifecycleHook } from "./lifecycle.js"
|
|
33
|
+
// Plan 09-01 Task 1: re-export of typed-slot interfaces so extension packages don't
|
|
34
|
+
// need to deep-import from @kronos-ts/messaging just to set/replace slots.
|
|
35
|
+
export type { TokenStore, TransactionManager } from "@kronos-ts/messaging"
|
|
36
|
+
// Plan 09-01 Task 2: handler enhancer + entities() tuple-shape types.
|
|
37
|
+
export type { HandlerEnhancerDefinition } from "@kronos-ts/messaging"
|
|
38
|
+
export type { StateOptions, StatesArg } from "./app.js"
|
package/src/kronos.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { StateModule } from "@kronos-ts/modelling"
|
|
2
|
+
import type {
|
|
3
|
+
CommandHandlerDefinition,
|
|
4
|
+
QueryHandlerDefinition,
|
|
5
|
+
EventProcessorModule,
|
|
6
|
+
} from "@kronos-ts/messaging"
|
|
7
|
+
import {
|
|
8
|
+
createInterceptingCommandBus,
|
|
9
|
+
createInterceptingQueryBus,
|
|
10
|
+
createInterceptingEventBus,
|
|
11
|
+
} from "@kronos-ts/messaging"
|
|
12
|
+
import { AppImpl, type App } from "./app.js"
|
|
13
|
+
import { registerInMemoryDefaults } from "./defaults.js"
|
|
14
|
+
import { createWarningChannel, type WarningLogger } from "./warnings.js"
|
|
15
|
+
import { Defaults } from "./defaults-handles.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Partial-config shorthand options for kronos(). APP-02.
|
|
19
|
+
*
|
|
20
|
+
* Domain registrations passed here are appended to the same internal accumulators
|
|
21
|
+
* as fluent .states()/.commands()/etc. calls. quiet/logger configure the warning
|
|
22
|
+
* channel BEFORE in-memory defaults are registered.
|
|
23
|
+
*/
|
|
24
|
+
export interface KronosPartialConfig {
|
|
25
|
+
states?: StateModule[]
|
|
26
|
+
commands?: CommandHandlerDefinition<any, any>[]
|
|
27
|
+
queries?: QueryHandlerDefinition[]
|
|
28
|
+
processors?: EventProcessorModule[]
|
|
29
|
+
quiet?: boolean
|
|
30
|
+
logger?: WarningLogger
|
|
31
|
+
/** Stable logical service/application name. Same across replicas. */
|
|
32
|
+
serviceName?: string
|
|
33
|
+
/** Unique physical runtime instance id. Different per process/pod. */
|
|
34
|
+
instanceId?: string
|
|
35
|
+
/**
|
|
36
|
+
* Per-stage timeout (ms) for native lifecycle execution (D-77).
|
|
37
|
+
* If a single stage exceeds this, AppImpl emits a warning and continues
|
|
38
|
+
* to the next stage WITHOUT cancelling the slow hooks (warn-then-continue).
|
|
39
|
+
*
|
|
40
|
+
* Default: 5000.
|
|
41
|
+
*/
|
|
42
|
+
stageTimeoutMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a new Kronos App.
|
|
47
|
+
*
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const app = await kronos()
|
|
50
|
+
* .states(Course)
|
|
51
|
+
* .commands(createCourseHandler)
|
|
52
|
+
* .start()
|
|
53
|
+
*
|
|
54
|
+
* await app.commandGateway.send(CreateCourse, { courseId: "cs-101", name: "Intro" }, emptyMetadata())
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* Or with a partial config (APP-02):
|
|
58
|
+
*
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const app = await kronos({ states: [Course], commands: [createCourseHandler], quiet: true }).start()
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function kronos(partial?: KronosPartialConfig): App {
|
|
64
|
+
const warningChannel = createWarningChannel({ quiet: partial?.quiet, logger: partial?.logger })
|
|
65
|
+
const app = new AppImpl({
|
|
66
|
+
warningChannel,
|
|
67
|
+
stageTimeoutMs: partial?.stageTimeoutMs,
|
|
68
|
+
serviceName: partial?.serviceName,
|
|
69
|
+
instanceId: partial?.instanceId,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Register in-memory defaults FIRST so user partial-config / fluent calls override them
|
|
73
|
+
// via set/forceSet (setDefault is ifAbsent — first registration wins).
|
|
74
|
+
registerInMemoryDefaults(app)
|
|
75
|
+
|
|
76
|
+
// Register framework-default `intercepting` decorators for the 3 buses (DEC-02, D-57).
|
|
77
|
+
// Each closure reads `app._state.{bus}DispatchInterceptors` + `app._state.handlerInterceptors`
|
|
78
|
+
// at decoration time (i.e., during .start()) — extensions and user code populate these
|
|
79
|
+
// arrays before .start() runs, so the snapshot is complete by the time the closure fires.
|
|
80
|
+
// Removable via `removeDecorator(Defaults.{bus}.intercepting)`.
|
|
81
|
+
app._registerFrameworkDefaultDecorator(
|
|
82
|
+
Defaults.commandBus.intercepting,
|
|
83
|
+
(inner, _resolved) => {
|
|
84
|
+
const wrapped = createInterceptingCommandBus(inner)
|
|
85
|
+
for (const fn of app._state.commandDispatchInterceptors) wrapped.registerDispatchInterceptor(fn)
|
|
86
|
+
for (const fn of app._state.handlerInterceptors) wrapped.registerHandlerInterceptor(fn)
|
|
87
|
+
return wrapped
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
app._registerFrameworkDefaultDecorator(
|
|
91
|
+
Defaults.queryBus.intercepting,
|
|
92
|
+
(inner, _resolved) => {
|
|
93
|
+
const wrapped = createInterceptingQueryBus(inner)
|
|
94
|
+
for (const fn of app._state.queryDispatchInterceptors) wrapped.registerDispatchInterceptor(fn)
|
|
95
|
+
for (const fn of app._state.handlerInterceptors) wrapped.registerHandlerInterceptor(fn)
|
|
96
|
+
return wrapped
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
app._registerFrameworkDefaultDecorator(
|
|
100
|
+
Defaults.eventBus.intercepting,
|
|
101
|
+
(inner, _resolved) => {
|
|
102
|
+
// EventBus intercepting wrapper takes its dispatch interceptors at construction time;
|
|
103
|
+
// it has no register* methods and no handler interceptor concept (verified
|
|
104
|
+
// packages/messaging/src/intercepting-event-bus.ts — no handlerInterceptors field).
|
|
105
|
+
// Snapshot the array at decoration time; mutations after .start() are blocked
|
|
106
|
+
// by the AppAlreadyStartedError guard on `eventDispatchInterceptor()`.
|
|
107
|
+
return createInterceptingEventBus(inner, [...app._state.eventDispatchInterceptors])
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if (partial?.states) app.states(...partial.states)
|
|
112
|
+
if (partial?.commands) app.commands(...partial.commands)
|
|
113
|
+
if (partial?.queries) app.queries(...partial.queries)
|
|
114
|
+
if (partial?.processors) app.processors(...partial.processors)
|
|
115
|
+
|
|
116
|
+
return app
|
|
117
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed lifecycle stages for app-level startup/shutdown hooks (LIF-01).
|
|
3
|
+
*
|
|
4
|
+
* Forward order on `.start()`: connect → warmup → register → processors → serve.
|
|
5
|
+
* Reverse order on `.stop()`: serve → processors → register → warmup → connect.
|
|
6
|
+
*
|
|
7
|
+
* Within a single stage, hooks execute in registration order (no `{stage, order}`
|
|
8
|
+
* tiebreaker — D-70 defers it until a real consumer asks).
|
|
9
|
+
*
|
|
10
|
+
* Closed string union by design (LIF-01). NOT extensible via declaration merging.
|
|
11
|
+
*/
|
|
12
|
+
export type LifecycleStage = "connect" | "register" | "warmup" | "processors" | "serve"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hook signature for `app.onStart(stage, fn)` / `app.onStop(stage, fn)` (LIF-02, D-69).
|
|
16
|
+
*
|
|
17
|
+
* Bare zero-arg shape. Extensions close over their own state from the enclosing
|
|
18
|
+
* `(app: App) => void` extension scope (see DESIGN.md §12 KronosDB pattern).
|
|
19
|
+
*
|
|
20
|
+
* Why no `Resolved` arg: the Resolved proxy exists for slot-factory composition
|
|
21
|
+
* (Phase 5 D-52); lifecycle hooks are a different concern.
|
|
22
|
+
*
|
|
23
|
+
* Why no `Configuration` arg: matching the legacy `LifecycleHandler(config?: any)`
|
|
24
|
+
* signature would couple this public type to the configurer's `Configuration` —
|
|
25
|
+
* a Phase 8 deletion target.
|
|
26
|
+
*/
|
|
27
|
+
export type LifecycleHook = () => void | Promise<void>
|
|
28
|
+
|
|
29
|
+
// Plan 08-03a (D-77): the legacy numeric-phase mapping is deleted. Native
|
|
30
|
+
// AppImpl.start() executes typed-stage hooks directly off
|
|
31
|
+
// AppState.startHooks/stopHooks — no numeric-phase bridge to the legacy
|
|
32
|
+
// LifecycleRegistry. Plan 03b's enhancer-bridge keeps a private inverted copy
|
|
33
|
+
// locally for the D-81 fallback path.
|
package/src/resolved.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { KronosComponents, SlotName } from "./components.js"
|
|
2
|
+
import { CircularSlotDependencyError, SlotNotRegisteredError } from "./errors.js"
|
|
3
|
+
import type { SlotRegistry } from "./slot-registry.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolved is structurally identical to KronosComponents — the destructured arg
|
|
7
|
+
* passed to factories. We re-export the alias so user code reads `Resolved` not `KronosComponents`.
|
|
8
|
+
*/
|
|
9
|
+
export type Resolved = KronosComponents
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a lazy + memoized Resolved Proxy backed by the registry. (D-52.)
|
|
13
|
+
*
|
|
14
|
+
* - Each property access invokes the slot's factory once and memoizes
|
|
15
|
+
* - Factories receive the same proxy as their argument so dependency chains compose
|
|
16
|
+
* - A `resolving` Set detects cycles: if a getter is hit while its slot is mid-resolution,
|
|
17
|
+
* throw CircularSlotDependencyError with the offending chain
|
|
18
|
+
* - try/finally ensures `resolving` is cleaned even after errors so subsequent accesses still work
|
|
19
|
+
*/
|
|
20
|
+
export function buildResolved(registry: SlotRegistry): Resolved {
|
|
21
|
+
const cache = new Map<SlotName, unknown>()
|
|
22
|
+
const resolving = new Set<SlotName>()
|
|
23
|
+
const resolvingOrder: SlotName[] = []
|
|
24
|
+
|
|
25
|
+
const handler: ProxyHandler<object> = {
|
|
26
|
+
get(_target, prop) {
|
|
27
|
+
const slot = prop as SlotName
|
|
28
|
+
if (cache.has(slot)) return cache.get(slot)
|
|
29
|
+
|
|
30
|
+
if (resolving.has(slot)) {
|
|
31
|
+
throw new CircularSlotDependencyError(slot, [...resolvingOrder, slot])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const entry = registry.getEntry(slot)
|
|
35
|
+
if (!entry) {
|
|
36
|
+
throw new SlotNotRegisteredError(slot)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resolving.add(slot)
|
|
40
|
+
resolvingOrder.push(slot)
|
|
41
|
+
try {
|
|
42
|
+
const value = entry.factory(proxy as Resolved)
|
|
43
|
+
cache.set(slot, value)
|
|
44
|
+
return value
|
|
45
|
+
} finally {
|
|
46
|
+
resolving.delete(slot)
|
|
47
|
+
resolvingOrder.pop()
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const proxy = new Proxy({}, handler) as Resolved
|
|
53
|
+
return proxy
|
|
54
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { KronosComponents, SlotName } from "./components.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optional metadata attached to a slot entry. Today the only field is
|
|
5
|
+
* `inMemory` + `warning` (D-51) — used by Plan 02 to emit startup warnings
|
|
6
|
+
* for any slot still using a flagged in-memory default at .start() time.
|
|
7
|
+
*/
|
|
8
|
+
export interface SlotMeta {
|
|
9
|
+
inMemory?: true
|
|
10
|
+
warning?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Each slot's resolution callable: a factory that takes the destructured Resolved and returns the component. */
|
|
14
|
+
export type SlotFactory<K extends SlotName> = (resolved: KronosComponents) => KronosComponents[K]
|
|
15
|
+
|
|
16
|
+
/** A registered slot entry: normalized factory + optional meta. */
|
|
17
|
+
export interface SlotEntry<K extends SlotName = SlotName> {
|
|
18
|
+
factory: SlotFactory<K>
|
|
19
|
+
meta?: SlotMeta
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize a factory-or-instance argument into a SlotFactory.
|
|
24
|
+
* SLT-03: plain instances (non-function values) become `() => instance` internally.
|
|
25
|
+
*/
|
|
26
|
+
function normalizeFactory<K extends SlotName>(
|
|
27
|
+
factoryOrInstance: SlotFactory<K> | KronosComponents[K],
|
|
28
|
+
): SlotFactory<K> {
|
|
29
|
+
if (typeof factoryOrInstance === "function") {
|
|
30
|
+
return factoryOrInstance as SlotFactory<K>
|
|
31
|
+
}
|
|
32
|
+
const instance = factoryOrInstance
|
|
33
|
+
return () => instance
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* SlotRegistry — the storage backing the App's three verbs.
|
|
38
|
+
* setDefault: ifAbsent (no-op if occupied)
|
|
39
|
+
* set: warn on double-set
|
|
40
|
+
* forceSet: silent overwrite
|
|
41
|
+
* (DESIGN.md §6, REQUIREMENTS.md SLT-02.)
|
|
42
|
+
*/
|
|
43
|
+
export class SlotRegistry {
|
|
44
|
+
private readonly slots = new Map<SlotName, SlotEntry>()
|
|
45
|
+
|
|
46
|
+
setDefault<K extends SlotName>(
|
|
47
|
+
slot: K,
|
|
48
|
+
factoryOrInstance: SlotFactory<K> | KronosComponents[K],
|
|
49
|
+
meta?: SlotMeta,
|
|
50
|
+
): void {
|
|
51
|
+
if (this.slots.has(slot)) return
|
|
52
|
+
this.slots.set(slot, { factory: normalizeFactory(factoryOrInstance) as SlotFactory<SlotName>, meta })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
set<K extends SlotName>(
|
|
56
|
+
slot: K,
|
|
57
|
+
factoryOrInstance: SlotFactory<K> | KronosComponents[K],
|
|
58
|
+
): void {
|
|
59
|
+
const existing = this.slots.get(slot)
|
|
60
|
+
// Provenance check: setDefault always writes a `meta` key (even when its value
|
|
61
|
+
// is undefined for stateless defaults like serializer / unitOfWorkFactory / tagResolver);
|
|
62
|
+
// set / forceSet never write the key at all. So `"meta" in existing` identifies a prior
|
|
63
|
+
// setDefault — those overrides are the expected "extension overrides in-memory default" path
|
|
64
|
+
// and should NOT warn. Genuine collisions (two set/forceSet calls on the same slot) DO warn.
|
|
65
|
+
if (existing && !("meta" in existing)) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[kronos] slot "${slot}" override: already set. Use forceSet() to suppress this warning.`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
this.slots.set(slot, { factory: normalizeFactory(factoryOrInstance) as SlotFactory<SlotName> })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
forceSet<K extends SlotName>(
|
|
74
|
+
slot: K,
|
|
75
|
+
factoryOrInstance: SlotFactory<K> | KronosComponents[K],
|
|
76
|
+
): void {
|
|
77
|
+
this.slots.set(slot, { factory: normalizeFactory(factoryOrInstance) as SlotFactory<SlotName> })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @internal — used by buildResolved and by Plan 02's warning emitter. */
|
|
81
|
+
getEntry<K extends SlotName>(slot: K): SlotEntry<K> | undefined {
|
|
82
|
+
return this.slots.get(slot) as SlotEntry<K> | undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @internal — used by Plan 02 to iterate registered slots for startup warnings. */
|
|
86
|
+
has(slot: SlotName): boolean {
|
|
87
|
+
return this.slots.has(slot)
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/warnings.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smallest viable logger interface (D-51) — just `warn(msg: string)`.
|
|
3
|
+
* Phase 5 only emits startup warnings for in-memory defaults; richer levels
|
|
4
|
+
* are intentionally NOT in scope.
|
|
5
|
+
*/
|
|
6
|
+
export interface WarningLogger {
|
|
7
|
+
warn(msg: string): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WarningChannelOptions {
|
|
11
|
+
/** When true, suppress all warnings (kronos({ quiet: true })). */
|
|
12
|
+
quiet?: boolean
|
|
13
|
+
/** When set, route warnings here instead of console.warn. `quiet` takes precedence over `logger`. */
|
|
14
|
+
logger?: WarningLogger
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WarningChannel {
|
|
18
|
+
emit(msg: string): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createWarningChannel(options: WarningChannelOptions = {}): WarningChannel {
|
|
22
|
+
return {
|
|
23
|
+
emit(msg: string): void {
|
|
24
|
+
if (options.quiet) return
|
|
25
|
+
if (options.logger) {
|
|
26
|
+
options.logger.warn(msg)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
console.warn(msg)
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|