@arki/dot 0.3.0 → 0.4.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 (113) hide show
  1. package/README.md +86 -86
  2. package/dist/cli/discover.js +2 -2
  3. package/dist/cli/discover.js.map +1 -1
  4. package/dist/cli/error-codes.d.ts +8 -0
  5. package/dist/cli/error-codes.d.ts.map +1 -1
  6. package/dist/cli/error-codes.js +12 -0
  7. package/dist/cli/error-codes.js.map +1 -1
  8. package/dist/cli/index.d.ts +9 -2
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +54 -23
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/cli/new.d.ts +1 -1
  13. package/dist/cli/new.js +1 -1
  14. package/dist/cli/render-doctor.d.ts +1 -1
  15. package/dist/cli/render-doctor.js +23 -23
  16. package/dist/cli/render-doctor.js.map +1 -1
  17. package/dist/cli/render-explain.d.ts.map +1 -1
  18. package/dist/cli/render-explain.js +33 -19
  19. package/dist/cli/render-explain.js.map +1 -1
  20. package/dist/cli/render-graph.d.ts +3 -3
  21. package/dist/cli/render-graph.js +12 -12
  22. package/dist/cli/render-graph.js.map +1 -1
  23. package/dist/cli/render-projection.d.ts +29 -0
  24. package/dist/cli/render-projection.d.ts.map +1 -0
  25. package/dist/cli/render-projection.js +202 -0
  26. package/dist/cli/render-projection.js.map +1 -0
  27. package/dist/define-app.d.ts +49 -17
  28. package/dist/define-app.d.ts.map +1 -1
  29. package/dist/define-app.js +16 -9
  30. package/dist/define-app.js.map +1 -1
  31. package/dist/diagnostics.d.ts +19 -15
  32. package/dist/diagnostics.d.ts.map +1 -1
  33. package/dist/diagnostics.js +2 -2
  34. package/dist/index.d.ts +15 -12
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +7 -5
  37. package/dist/index.js.map +1 -1
  38. package/dist/init-pips.d.ts +45 -0
  39. package/dist/init-pips.d.ts.map +1 -0
  40. package/dist/init-pips.js +49 -0
  41. package/dist/init-pips.js.map +1 -0
  42. package/dist/init-plugins.d.ts +45 -0
  43. package/dist/init-plugins.d.ts.map +1 -0
  44. package/dist/init-plugins.js +49 -0
  45. package/dist/init-plugins.js.map +1 -0
  46. package/dist/kernel/app-instance.d.ts +5 -5
  47. package/dist/kernel/app-instance.d.ts.map +1 -1
  48. package/dist/kernel/app-instance.js +375 -244
  49. package/dist/kernel/app-instance.js.map +1 -1
  50. package/dist/kernel/otel.d.ts +19 -19
  51. package/dist/kernel/otel.d.ts.map +1 -1
  52. package/dist/kernel/otel.js +22 -22
  53. package/dist/kernel/otel.js.map +1 -1
  54. package/dist/lifecycle-observer.d.ts +10 -10
  55. package/dist/lifecycle-observer.d.ts.map +1 -1
  56. package/dist/lifecycle-observer.js +1 -1
  57. package/dist/lifecycle.d.ts +18 -14
  58. package/dist/lifecycle.d.ts.map +1 -1
  59. package/dist/lifecycle.js +13 -9
  60. package/dist/lifecycle.js.map +1 -1
  61. package/dist/manifest.d.ts +70 -48
  62. package/dist/manifest.d.ts.map +1 -1
  63. package/dist/manifest.js +74 -5
  64. package/dist/manifest.js.map +1 -1
  65. package/dist/pip-contract.d.ts +29 -21
  66. package/dist/pip-contract.d.ts.map +1 -1
  67. package/dist/pip-contract.js +1 -0
  68. package/dist/pip-contract.js.map +1 -1
  69. package/dist/pip.d.ts +7 -4
  70. package/dist/pip.d.ts.map +1 -1
  71. package/dist/pip.js +2 -0
  72. package/dist/pip.js.map +1 -1
  73. package/dist/plugin-contract.d.ts +335 -143
  74. package/dist/plugin-contract.d.ts.map +1 -1
  75. package/dist/plugin-contract.js +201 -36
  76. package/dist/plugin-contract.js.map +1 -1
  77. package/dist/plugin.d.ts +18 -15
  78. package/dist/plugin.d.ts.map +1 -1
  79. package/dist/plugin.js +11 -11
  80. package/dist/plugin.js.map +1 -1
  81. package/dist/test-harness.d.ts +31 -31
  82. package/dist/test-harness.d.ts.map +1 -1
  83. package/dist/test-harness.js +29 -28
  84. package/dist/test-harness.js.map +1 -1
  85. package/dist/timeline.d.ts +2 -2
  86. package/dist/timeline.js +5 -5
  87. package/dist/timeline.js.map +1 -1
  88. package/package.json +9 -9
  89. package/src/cli/discover.ts +2 -2
  90. package/src/cli/error-codes.ts +12 -0
  91. package/src/cli/index.ts +76 -29
  92. package/src/cli/new.ts +1 -1
  93. package/src/cli/render-doctor.ts +21 -21
  94. package/src/cli/render-explain.ts +37 -19
  95. package/src/cli/render-graph.ts +12 -12
  96. package/src/cli/render-projection.ts +239 -0
  97. package/src/define-app.ts +85 -22
  98. package/src/diagnostics.ts +19 -15
  99. package/src/index.ts +37 -20
  100. package/src/init-plugins.ts +142 -0
  101. package/src/kernel/app-instance.ts +428 -282
  102. package/src/kernel/otel.ts +27 -27
  103. package/src/lifecycle-observer.ts +10 -10
  104. package/src/lifecycle.ts +19 -15
  105. package/src/manifest.ts +128 -50
  106. package/src/{pip-contract.ts → plugin-contract.ts} +73 -62
  107. package/src/{pip.ts → plugin.ts} +37 -18
  108. package/src/test-harness.ts +52 -51
  109. package/src/timeline.ts +6 -6
  110. package/templates/app-minimal/AGENTS.md.tmpl +9 -9
  111. package/templates/app-minimal/README.md.tmpl +1 -1
  112. package/templates/app-minimal/tests/boot.test.ts.tmpl +1 -1
  113. package/src/cli/render-openapi.ts +0 -147
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OpenTelemetry instrumentation for the DOT kernel.
3
3
  *
4
- * DOT is OTel-first: every lifecycle phase and every per-pip hook
4
+ * DOT is OTel-first: every lifecycle phase and every per-plugin hook
5
5
  * automatically emits a span and a duration histogram. When no SDK is
6
6
  * registered, the OTel API returns no-op implementations and the kernel
7
7
  * pays zero allocation per call — the discipline that makes "OTel-first"
@@ -15,11 +15,11 @@
15
15
  * - Phase span: `dot.app.<phase>` — one root span per `configure()` /
16
16
  * `boot()` / `start()` / `stop()` / `dispose()` call. Children
17
17
  * auto-link via async-context.
18
- * - Hook span: `dot.pip.<hook>` — one child span per pip's hook
19
- * execution. The pip name lives on `dot.pip.name`, not in the span
20
- * name, so backends can aggregate by hook across pips.
21
- * - Attributes (`dot.app.name`, `dot.pip.name`, `dot.pip.version`,
22
- * `dot.hook`, `dot.pip.order`) follow the OTel convention of
18
+ * - Hook span: `dot.plugin.<hook>` — one child span per plugin's hook
19
+ * execution. The plugin name lives on `dot.plugin.name`, not in the span
20
+ * name, so backends can aggregate by hook across plugins.
21
+ * - Attributes (`dot.app.name`, `dot.plugin.name`, `dot.plugin.version`,
22
+ * `dot.hook`, `dot.plugin.order`) follow the OTel convention of
23
23
  * namespacing under the library's prefix.
24
24
  *
25
25
  * @see packages/dot/docs/principles.md — principle 3 (deterministic) +
@@ -41,16 +41,16 @@ export const tracer = trace.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERS
41
41
  export const meter = metrics.getMeter(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
42
42
 
43
43
  /**
44
- * Histogram recording per-pip per-hook execution duration in milliseconds.
44
+ * Histogram recording per-plugin per-hook execution duration in milliseconds.
45
45
  *
46
46
  * Attributes:
47
47
  * - `dot.app.name` — app name
48
- * - `dot.pip.name` — pip name
48
+ * - `dot.plugin.name` — plugin name
49
49
  * - `dot.hook` — which lifecycle hook (`configure` | `boot` | ...)
50
50
  * - `dot.status` — `ok` | `failed`
51
51
  */
52
- export const hookDurationHistogram = meter.createHistogram('dot.pip.hook.duration', {
53
- description: 'Wall-clock duration of a single pip lifecycle hook execution.',
52
+ export const hookDurationHistogram = meter.createHistogram('dot.plugin.hook.duration', {
53
+ description: 'Wall-clock duration of a single plugin lifecycle hook execution.',
54
54
  unit: 'ms',
55
55
  });
56
56
 
@@ -95,44 +95,44 @@ function stringifyForSpan(value: unknown): string {
95
95
  }
96
96
 
97
97
  /**
98
- * Wrap a single pip-hook execution in a child span and record its
98
+ * Wrap a single plugin-hook execution in a child span and record its
99
99
  * duration in the histogram. The span auto-links to the active phase
100
100
  * span (set by {@link withPhaseSpan}) so traces show a clean parent-child
101
101
  * hierarchy:
102
102
  *
103
103
  * dot.app.boot
104
- * ├── dot.pip.boot (dot.pip.name=env, dot.hook=boot)
105
- * ├── dot.pip.boot (dot.pip.name=db, dot.hook=boot)
106
- * └── dot.pip.boot (dot.pip.name=kv, dot.hook=boot)
104
+ * ├── dot.plugin.boot (dot.plugin.name=env, dot.hook=boot)
105
+ * ├── dot.plugin.boot (dot.plugin.name=db, dot.hook=boot)
106
+ * └── dot.plugin.boot (dot.plugin.name=kv, dot.hook=boot)
107
107
  *
108
108
  * Re-throws on failure after marking the span ERROR so the caller's
109
109
  * existing error-handling path stays intact.
110
110
  */
111
- export function withPipHookSpan<R>(
111
+ export function withPluginHookSpan<R>(
112
112
  opts: {
113
113
  readonly appName: string;
114
- readonly pipName: string;
115
- readonly pipVersion?: string;
114
+ readonly pluginName: string;
115
+ readonly pluginVersion?: string;
116
116
  readonly hook: DotLifecycleHook;
117
117
  readonly order: number;
118
118
  /**
119
- * Optional pip-scoped logger. When present, the helper threads the
119
+ * Optional plugin-scoped logger. When present, the helper threads the
120
120
  * span's `traceId` + `spanId` onto the logger via `setTraceContext`
121
121
  * before invoking the body — every log line emitted from within the
122
- * pip's hook is automatically correlated with this span in the
122
+ * plugin's hook is automatically correlated with this span in the
123
123
  * `traceId`/`spanId` fields of `LogRecord`.
124
124
  */
125
125
  readonly logger?: Logger;
126
126
  },
127
127
  fn: (span: Span) => R,
128
128
  ): R {
129
- const span = tracer.startSpan(`dot.pip.${opts.hook}`, {
129
+ const span = tracer.startSpan(`dot.plugin.${opts.hook}`, {
130
130
  attributes: attrs({
131
131
  'dot.app.name': opts.appName,
132
- 'dot.pip.name': opts.pipName,
133
- 'dot.pip.version': opts.pipVersion,
132
+ 'dot.plugin.name': opts.pluginName,
133
+ 'dot.plugin.version': opts.pluginVersion,
134
134
  'dot.hook': opts.hook,
135
- 'dot.pip.order': opts.order,
135
+ 'dot.plugin.order': opts.order,
136
136
  }),
137
137
  });
138
138
  if (opts.logger) {
@@ -145,7 +145,7 @@ export function withPipHookSpan<R>(
145
145
  const durationMs = performance.now() - started;
146
146
  hookDurationHistogram.record(durationMs, {
147
147
  'dot.app.name': opts.appName,
148
- 'dot.pip.name': opts.pipName,
148
+ 'dot.plugin.name': opts.pluginName,
149
149
  'dot.hook': opts.hook,
150
150
  'dot.status': status,
151
151
  });
@@ -183,7 +183,7 @@ export function withPipHookSpan<R>(
183
183
 
184
184
  /**
185
185
  * Wrap an entire app-level phase (`configure`/`boot`/`start`/`stop`/`dispose`)
186
- * in an active span. Children created by {@link withPipHookSpan} inherit
186
+ * in an active span. Children created by {@link withPluginHookSpan} inherit
187
187
  * this span as their parent via async-context.
188
188
  *
189
189
  * The callback's return value (sync or Promise) is propagated unchanged
@@ -194,7 +194,7 @@ export function withPhaseSpan<R>(
194
194
  readonly appName: string;
195
195
  readonly appVersion?: string;
196
196
  readonly phase: DotLifecycleHook;
197
- readonly pipCount: number;
197
+ readonly pluginCount: number;
198
198
  /**
199
199
  * Optional phase-scoped logger. When present, the helper threads
200
200
  * the span's `traceId` + `spanId` onto the logger before invoking
@@ -213,7 +213,7 @@ export function withPhaseSpan<R>(
213
213
  'dot.app.name': opts.appName,
214
214
  'dot.app.version': opts.appVersion,
215
215
  'dot.app.phase': opts.phase,
216
- 'dot.app.pip.count': opts.pipCount,
216
+ 'dot.app.plugin.count': opts.pluginCount,
217
217
  }),
218
218
  },
219
219
  (span): R => {
@@ -2,7 +2,7 @@
2
2
  * Lifecycle observer surface for the DOT kernel.
3
3
  *
4
4
  * `DotLifecycleObserver` is the in-process companion to the OTel signals
5
- * emitted by {@link withPhaseSpan} / {@link withPipHookSpan}. Where OTel
5
+ * emitted by {@link withPhaseSpan} / {@link withPluginHookSpan}. Where OTel
6
6
  * is the contract for cross-process tracing (consumers register an SDK
7
7
  * and ship spans to a backend), the observer is the contract for *local*
8
8
  * programmatic inspection — used by tests, CLI tooling, ASCII waterfalls,
@@ -24,7 +24,7 @@ import type { DotLifecycleHook } from './lifecycle.js';
24
24
  * observer. Discriminated by `kind`:
25
25
  *
26
26
  * - `'phase'` — boundary of a top-level lifecycle phase
27
- * - `'pip-hook'` — boundary of a single pip's hook execution
27
+ * - `'plugin-hook'` — boundary of a single plugin's hook execution
28
28
  *
29
29
  * Status discriminates the boundary itself:
30
30
  *
@@ -35,7 +35,7 @@ import type { DotLifecycleHook } from './lifecycle.js';
35
35
  * `starting` events never carry `durationMs` / `error`. `completed` events
36
36
  * carry `durationMs`. `failed` events carry both `durationMs` and `error`.
37
37
  */
38
- export type DotLifecycleEvent = DotPhaseLifecycleEvent | DotPipHookLifecycleEvent;
38
+ export type DotLifecycleEvent = DotPhaseLifecycleEvent | DotPluginHookLifecycleEvent;
39
39
 
40
40
  /** Possible event statuses. See {@link DotLifecycleEvent}. */
41
41
  export type DotLifecycleEventStatus = 'starting' | 'completed' | 'failed';
@@ -56,14 +56,14 @@ export type DotPhaseLifecycleEvent = {
56
56
  readonly timestamp: number;
57
57
  };
58
58
 
59
- /** Boundary of a single pip's hook execution. */
60
- export type DotPipHookLifecycleEvent = {
61
- readonly kind: 'pip-hook';
59
+ /** Boundary of a single plugin's hook execution. */
60
+ export type DotPluginHookLifecycleEvent = {
61
+ readonly kind: 'plugin-hook';
62
62
  /** The phase the hook belongs to. */
63
63
  readonly phase: DotLifecycleHook;
64
- /** The pip whose hook is being executed. */
65
- readonly pip: string;
66
- /** Topological order of the pip within the phase (0-based). */
64
+ /** The plugin whose hook is being executed. */
65
+ readonly plugin: string;
66
+ /** Topological order of the plugin within the phase (0-based). */
67
67
  readonly order: number;
68
68
  readonly status: DotLifecycleEventStatus;
69
69
  readonly appName: string;
@@ -91,7 +91,7 @@ export type DotPipHookLifecycleEvent = {
91
91
  * const app = await defineApp('my-app', {
92
92
  * observers: [(event) => events.push(event)],
93
93
  * })
94
- * .use(myPip)
94
+ * .use(myPlugin)
95
95
  * .boot();
96
96
  *
97
97
  * // events now contains the full configure + boot event stream.
package/src/lifecycle.ts CHANGED
@@ -20,7 +20,7 @@ export type DotLifecycleHook = 'configure' | 'boot' | 'start' | 'stop' | 'dispos
20
20
 
21
21
  /**
22
22
  * The complete set of lifecycle hooks in execution order.
23
- * `stop` and `dispose` run in reverse declaration order across pips, but the
23
+ * `stop` and `dispose` run in reverse declaration order across plugins, but the
24
24
  * sequence of hooks themselves is always `configure -> boot -> start -> stop -> dispose`.
25
25
  */
26
26
  export const DOT_LIFECYCLE_HOOKS: readonly DotLifecycleHook[] = [
@@ -68,16 +68,20 @@ export const DotLifecycleErrorCode = {
68
68
  // v2 wiring model — declaration order is boot order, so cycles and
69
69
  // name-based dependency declarations no longer exist. Codes are never
70
70
  // reused for new meanings.
71
- /** Pip registered twice. */
72
- DuplicatePip: 'DOT_LIFECYCLE_E011',
73
- /** A pip's `needs` entry has no provider among earlier-booted pips. */
71
+ /** Plugin registered twice. */
72
+ DuplicatePlugin: 'DOT_LIFECYCLE_E011',
73
+ /** A plugin's `needs` entry has no provider among earlier-booted plugins. */
74
74
  UnsatisfiedNeed: 'DOT_LIFECYCLE_E012',
75
- /** A pip published a wire key that an earlier pip already provides. */
75
+ /** A plugin published a wire key that an earlier plugin already provides. */
76
76
  ServiceCollision: 'DOT_LIFECYCLE_E013',
77
77
  /** A needs alias, publish key, or rename target uses the reserved `$` prefix. */
78
78
  ReservedServiceKey: 'DOT_LIFECYCLE_E014',
79
79
  /** A lifecycle hook exceeded the app's `hookTimeoutMs` watchdog. */
80
80
  HookTimeout: 'DOT_LIFECYCLE_E015',
81
+ /** Two actions in the same binding use the same id. */
82
+ DuplicateAction: 'DOT_LIFECYCLE_E016',
83
+ /** Action metadata is not JSON-round-trip safe. */
84
+ ActionMetaNotJson: 'DOT_LIFECYCLE_E017',
81
85
  } as const;
82
86
 
83
87
  export type DotLifecycleErrorCodeValue = (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
@@ -88,38 +92,38 @@ export type DotLifecycleErrorCodeValue = (typeof DotLifecycleErrorCode)[keyof ty
88
92
  * Carries:
89
93
  * - `code` — stable machine-readable error code (see {@link DotLifecycleErrorCode}).
90
94
  * - `phase` — which hook (or pseudo-hook) failed.
91
- * - `pip` — which pip name, when applicable.
95
+ * - `plugin` — which plugin name, when applicable.
92
96
  * - `cause` — original error if wrapped from a hook throw.
93
- * - `failures` — for aggregate errors (stop/dispose), the per-pip failures.
97
+ * - `failures` — for aggregate errors (stop/dispose), the per-plugin failures.
94
98
  */
95
99
  export class DotLifecycleError extends Error {
96
100
  readonly code: DotLifecycleErrorCodeValue;
97
101
  readonly phase: DotLifecycleHook;
98
- readonly pip?: string;
102
+ readonly plugin?: string;
99
103
  override readonly cause?: unknown;
100
- readonly failures?: readonly DotLifecyclePipFailure[];
104
+ readonly failures?: readonly DotLifecyclePluginFailure[];
101
105
 
102
106
  constructor(args: {
103
107
  code: DotLifecycleErrorCodeValue;
104
108
  phase: DotLifecycleHook;
105
109
  message: string;
106
- pip?: string;
110
+ plugin?: string;
107
111
  cause?: unknown;
108
- failures?: readonly DotLifecyclePipFailure[];
112
+ failures?: readonly DotLifecyclePluginFailure[];
109
113
  }) {
110
114
  super(args.message);
111
115
  this.name = 'DotLifecycleError';
112
116
  this.code = args.code;
113
117
  this.phase = args.phase;
114
- this.pip = args.pip;
118
+ this.plugin = args.plugin;
115
119
  this.cause = args.cause;
116
120
  this.failures = args.failures;
117
121
  }
118
122
  }
119
123
 
120
- /** Single pip failure inside an aggregate lifecycle error (stop/dispose). */
121
- export type DotLifecyclePipFailure = {
122
- pip: string;
124
+ /** Single plugin failure inside an aggregate lifecycle error (stop/dispose). */
125
+ export type DotLifecyclePluginFailure = {
126
+ plugin: string;
123
127
  phase: DotLifecycleHook;
124
128
  error: unknown;
125
129
  };
package/src/manifest.ts CHANGED
@@ -2,24 +2,96 @@
2
2
  * Manifest types for the DOT kernel.
3
3
  *
4
4
  * A `DotAppManifest` is the static, declarative description of an app: which
5
- * pips are registered, what routes/services they contribute, and how they
5
+ * plugins are registered, what actions/services/projections they contribute, and how they
6
6
  * depend on each other. It is built up during the `configure` phase from
7
7
  * registration calls and finalised once `configure` completes.
8
8
  *
9
- * CONTRACT: `DotAppManifest` always exposes the same five top-level arrays
10
- * (`pips`, `routes`, `services`, `lifecycle`, `dependencies`). Consumers
11
- * MUST NOT see an omitted array — empty is empty, but never missing.
12
- * This shape is referenced by Task 7's scorecard.
9
+ * CONTRACT: `DotAppManifest` always exposes the same six top-level arrays
10
+ * (`plugins`, `actions`, `services`, `lifecycle`, `dependencies`,
11
+ * `projections`). Consumers MUST NOT see an omitted array — empty is empty,
12
+ * but never missing.
13
13
  */
14
14
 
15
15
  import type { DotLifecycleHook } from './lifecycle.js';
16
16
 
17
+ /** JSON-serializable value — the manifest's currency. */
18
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
19
+
20
+ /** JSON-serializable object used for adapter-owned manifest metadata. */
21
+ export type JsonObject = { [key: string]: JsonValue };
22
+
23
+ function jsonRoundTrip(value: unknown): unknown {
24
+ let serialized: string;
25
+ try {
26
+ serialized = JSON.stringify(value);
27
+ } catch (error) {
28
+ throw new TypeError('Value must be JSON-serializable manifest data.', { cause: error });
29
+ }
30
+ if (serialized === undefined) {
31
+ throw new TypeError('Value must be a JSON-serializable object.');
32
+ }
33
+ return JSON.parse(serialized) as unknown;
34
+ }
35
+
36
+ function isJsonPrimitive(value: unknown): value is string | number | boolean | null {
37
+ return value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number';
38
+ }
39
+
40
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
41
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
42
+ const prototype = Object.getPrototypeOf(value) as unknown;
43
+ return prototype === Object.prototype || prototype === null;
44
+ }
45
+
46
+ function isJsonValue(value: unknown): value is JsonValue {
47
+ if (isJsonPrimitive(value)) return typeof value !== 'number' || Number.isFinite(value);
48
+ if (Array.isArray(value)) return value.every(isJsonValue);
49
+ if (!isPlainObject(value)) return false;
50
+ return Object.values(value).every(isJsonValue);
51
+ }
52
+
53
+ function isJsonObject(value: unknown): value is JsonObject {
54
+ return isPlainObject(value) && Object.values(value).every(isJsonValue);
55
+ }
56
+
57
+ function jsonDeepEqual(left: unknown, right: unknown): boolean {
58
+ if (isJsonPrimitive(left) || isJsonPrimitive(right)) return Object.is(left, right);
59
+ if (Array.isArray(left) || Array.isArray(right)) {
60
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
61
+ return left.every((value, index) => jsonDeepEqual(value, right[index]));
62
+ }
63
+ if (!isPlainObject(left) || !isPlainObject(right)) return false;
64
+ const leftEntries = Object.entries(left);
65
+ const rightEntries = Object.entries(right);
66
+ if (leftEntries.length !== rightEntries.length) return false;
67
+ for (const [key, value] of leftEntries) {
68
+ if (!Object.hasOwn(right, key) || !jsonDeepEqual(value, right[key])) return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Validate and narrow unknown adapter data into manifest JSON metadata.
75
+ *
76
+ * The check intentionally compares the original value against a
77
+ * JSON.stringify/parse round trip. Dates, functions, undefined fields, NaN,
78
+ * Infinity, class instances, and cycles fail instead of being silently
79
+ * coerced in `dot explain --json`.
80
+ */
81
+ export function toJsonObject(value: unknown): JsonObject {
82
+ const parsed = jsonRoundTrip(value);
83
+ if (!isJsonObject(parsed) || !jsonDeepEqual(value, parsed)) {
84
+ throw new TypeError('Value must be a JSON-serializable object without lossy coercions.');
85
+ }
86
+ return parsed;
87
+ }
88
+
17
89
  /**
18
- * Kind of service that a pip can publish.
90
+ * Kind of service that a plugin can publish.
19
91
  *
20
92
  * Well-known kinds map 1:1 to the canonical `@arki/*` adapters. `custom`
21
- * is the escape hatch for pip-defined service kinds — use it whenever a
22
- * pip publishes something that does not fit a reserved shape.
93
+ * is the escape hatch for plugin-defined service kinds — use it whenever a
94
+ * plugin publishes something that does not fit a reserved shape.
23
95
  */
24
96
  export type ServiceKind =
25
97
  | 'env'
@@ -33,79 +105,85 @@ export type ServiceKind =
33
105
  | 'message-bus'
34
106
  | 'custom';
35
107
 
36
- /** Transport that a route is exposed under. */
37
- export type RouteTransport = 'http' | 'orpc' | 'trpc' | 'rpc' | 'custom';
38
-
39
- /** Kind of dependency edge between two pips. */
108
+ /** Kind of dependency edge between two plugins. */
40
109
  export type DependencyEdgeKind = 'requires' | 'provides' | 'uses';
41
110
 
111
+ /** Direction of a boundary interaction, viewed from the app. */
112
+ export type ActionDirection = 'in' | 'out';
113
+
114
+ /** Something the app does at a boundary — universal, serializable, tiny. */
115
+ export type ActionManifest = {
116
+ /** Stable identifier, e.g. `orders.list` or `orders.created.consume`. */
117
+ id: string;
118
+ plugin: string;
119
+ /** Adapter-owned binding name: `http`, `queue`, `cli`, `cron`, etc. */
120
+ binding: string;
121
+ /** `in` = world invokes/feeds the app; `out` = app emits. */
122
+ direction: ActionDirection;
123
+ /** Display-only address printed by `dot explain`. */
124
+ address?: string;
125
+ /** Human summary rendered into explain output and projected documents. */
126
+ summary?: string;
127
+ /** Adapter-owned, JSON-serializable detail. Opaque to the kernel. */
128
+ meta?: Readonly<JsonObject>;
129
+ /** Optional adapter-owned identifier+version for the meta layout. */
130
+ metaSchema?: string;
131
+ };
132
+
133
+ /** Registered projection renderer for an adapter-owned document format. */
134
+ export type ProjectionManifest = {
135
+ /** Document format key matched by `dot explain --as <format>`. */
136
+ format: string;
137
+ /** Binding this projection primarily renders. Informational. */
138
+ binding: string;
139
+ /** Import specifier for the projection module. */
140
+ module: string;
141
+ /** Plugin that registered this projection. */
142
+ plugin: string;
143
+ };
144
+
42
145
  /**
43
146
  * Top-level manifest describing the static shape of a DOT app.
44
147
  *
45
- * Always carries the five arrays — never omits any of them.
148
+ * Always carries the six arrays — never omits any of them.
46
149
  */
47
150
  export type DotAppManifest = {
151
+ /** Manifest schema version. 2 = actions/projections era. */
152
+ manifestVersion: 2;
48
153
  app: {
49
154
  name: string;
50
155
  version?: string;
51
156
  };
52
- pips: PipManifest[];
53
- routes: RouteManifest[];
157
+ plugins: PluginManifest[];
158
+ actions: ActionManifest[];
54
159
  services: ServiceManifest[];
55
160
  lifecycle: LifecycleManifest[];
56
161
  dependencies: DependencyEdge[];
162
+ projections: ProjectionManifest[];
57
163
  };
58
164
 
59
- /** Single pip's declarative metadata. */
60
- export type PipManifest = {
165
+ /** Single plugin's declarative metadata. */
166
+ export type PluginManifest = {
61
167
  name: string;
62
168
  version?: string;
63
169
  dependencies: readonly string[];
64
170
  provides: readonly string[];
65
171
  };
66
172
 
67
- /** Single route exposed by a pip. */
68
- export type RouteManifest = {
69
- id: string;
70
- pip: string;
71
- method?: string;
72
- path?: string;
73
- transport: RouteTransport;
74
- /** Human summary, rendered into docs/OpenAPI output. */
75
- description?: string;
76
- /**
77
- * JSON Schema for the request inputs, keyed by location. Adapters
78
- * convert their validation schemas (e.g. zod) at `configure` time so the
79
- * manifest stays plain serializable data — `dot explain --openapi`
80
- * renders straight from here without booting.
81
- */
82
- input?: {
83
- readonly query?: Readonly<Record<string, unknown>>;
84
- readonly body?: Readonly<Record<string, unknown>>;
85
- };
86
- /** JSON Schema for the success response (or per-event schema when `streaming`). */
87
- output?: Readonly<Record<string, unknown>>;
88
- /**
89
- * The response is an open stream (e.g. `text/event-stream`) rather than
90
- * a single JSON document.
91
- */
92
- streaming?: boolean;
93
- };
94
-
95
- /** Single service published by a pip. */
173
+ /** Single service published by a plugin. */
96
174
  export type ServiceManifest = {
97
175
  name: string;
98
- pip: string;
176
+ plugin: string;
99
177
  kind: ServiceKind;
100
178
  };
101
179
 
102
- /** Which lifecycle hooks a pip participates in. */
180
+ /** Which lifecycle hooks a plugin participates in. */
103
181
  export type LifecycleManifest = {
104
- pip: string;
182
+ plugin: string;
105
183
  hooks: readonly DotLifecycleHook[];
106
184
  };
107
185
 
108
- /** Directed edge in the pip dependency graph. */
186
+ /** Directed edge in the plugin dependency graph. */
109
187
  export type DependencyEdge = {
110
188
  from: string;
111
189
  to: string;