@nwire/scan 0.10.1 → 0.11.1

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 (3) hide show
  1. package/dist/scan.d.ts +65 -13
  2. package/dist/scan.js +183 -23
  3. package/package.json +5 -5
package/dist/scan.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * `@nwire/scan` — produces the `.nwire/` cache by inspecting booted apps.
3
3
  *
4
- * Each input must be a `ForgeApp` (or any object exposing `appName`,
5
- * `dispatcher()`, `container`, `runtime`). The scanner walks the
6
- * dispatcher metadata maps to collect actions, actors, projections,
7
- * queries, workflows, and external calls, and reads `container.list()`
8
- * for DI bindings + `runtime.listHooks()` for hooks.
4
+ * Each input is an `App` (or any object exposing `appName`, `container`,
5
+ * `runtime`). When forge is installed, the scanner walks the dispatcher
6
+ * metadata maps to collect actions, actors, projections, queries,
7
+ * workflows, and external calls. It also reads `container.list()` for
8
+ * DI bindings and `runtime.listHooks()` for hooks.
9
9
  *
10
10
  * Callers are responsible for booting their apps before scanning — the
11
11
  * forge plugin populates the dispatcher during `app.start()`.
@@ -18,9 +18,28 @@ export interface SourceLocationEntry {
18
18
  export interface ActionEntry {
19
19
  readonly name: string;
20
20
  readonly app: string;
21
- readonly module?: string;
21
+ readonly description?: string;
22
22
  readonly public: boolean;
23
23
  readonly inputSchema?: unknown;
24
+ /** Event names this action emits, in declaration order. */
25
+ readonly emits: readonly string[];
26
+ /** True when a handler was wired via defineAction({handler}) — false for split definitions. */
27
+ readonly hasInlineHandler: boolean;
28
+ readonly persona?: string;
29
+ readonly journeyStep?: string;
30
+ readonly capability?: string;
31
+ readonly slo?: {
32
+ p95LatencyMs?: number;
33
+ successRate?: number;
34
+ };
35
+ readonly retry?: {
36
+ max: number;
37
+ backoff?: string;
38
+ baseDelayMs?: number;
39
+ maxDelayMs?: number;
40
+ };
41
+ readonly policy?: string | readonly string[] | readonly [action: string, subject: string];
42
+ readonly tags?: readonly string[];
24
43
  readonly source?: SourceLocationEntry;
25
44
  }
26
45
  export interface ExternalCallEntry {
@@ -58,42 +77,62 @@ export interface WorkflowEntry {
58
77
  readonly name: string;
59
78
  readonly app: string;
60
79
  readonly public: boolean;
80
+ readonly description?: string;
81
+ /** Event names this workflow listens to. */
82
+ readonly subscribesTo: readonly string[];
83
+ /** Action names this workflow dispatches inside its body. */
84
+ readonly dispatches: readonly string[];
61
85
  readonly source?: SourceLocationEntry;
62
86
  }
63
87
  export interface EventEntry {
64
88
  readonly name: string;
65
89
  readonly app: string;
66
90
  readonly public: boolean;
91
+ readonly description?: string;
92
+ readonly version?: number;
93
+ /** Free-form labels for audience filtering ("product", "analytics", "partner-billing"). */
94
+ readonly audience?: readonly string[];
67
95
  readonly source?: SourceLocationEntry;
68
96
  }
69
97
  export interface ActorEntry {
70
98
  readonly name: string;
71
99
  readonly app: string;
100
+ /** State names declared in the actor's `states` map. */
101
+ readonly states: readonly string[];
72
102
  readonly source?: SourceLocationEntry;
73
103
  }
74
104
  export interface ProjectionEntry {
75
105
  readonly name: string;
76
106
  readonly app: string;
107
+ readonly description?: string;
108
+ /** Event names this projection folds in via its `listens` array. */
109
+ readonly listens: readonly string[];
77
110
  readonly source?: SourceLocationEntry;
78
111
  }
79
112
  export interface QueryEntry {
80
113
  readonly name: string;
81
114
  readonly app: string;
82
115
  readonly public: boolean;
83
- readonly source?: SourceLocationEntry;
84
- }
85
- export interface ModuleEntry {
86
- readonly name: string;
87
- readonly app: string;
116
+ /** Name of the projection this query reads from (projection-form queries only). */
117
+ readonly projection?: string;
88
118
  readonly source?: SourceLocationEntry;
89
119
  }
90
120
  export interface AppEntry {
91
121
  readonly name: string;
92
122
  readonly description?: string;
93
- readonly modules: readonly string[];
123
+ /** Plugin names installed on this app, in install order. */
124
+ readonly plugins: readonly string[];
94
125
  readonly tenantModel?: string;
95
126
  readonly tenantKey?: string;
96
127
  }
128
+ export interface SinkEntry {
129
+ readonly name: string;
130
+ readonly app: string;
131
+ /** Adapter kind tag — "bullmq", "nats", "capture", etc. */
132
+ readonly kind?: string;
133
+ readonly position: "early" | "middle" | "terminal";
134
+ readonly direction: "outbound";
135
+ }
97
136
  export interface RouteEntry {
98
137
  readonly method: string;
99
138
  readonly path: string;
@@ -161,7 +200,6 @@ export interface EventGraphEdge {
161
200
  export interface Cache {
162
201
  readonly generatedAt: string;
163
202
  readonly apps: readonly AppEntry[];
164
- readonly modules: readonly ModuleEntry[];
165
203
  readonly actions: readonly ActionEntry[];
166
204
  readonly events: readonly EventEntry[];
167
205
  readonly actors: readonly ActorEntry[];
@@ -178,6 +216,7 @@ export interface Cache {
178
216
  readonly commands: readonly CommandEntry[];
179
217
  readonly hooks: readonly HookEntry[];
180
218
  readonly plugins: readonly PluginEntry[];
219
+ readonly sinks: readonly SinkEntry[];
181
220
  readonly bindings: readonly DIBindingEntry[];
182
221
  readonly resources: readonly ResourceEntry[];
183
222
  readonly errors: readonly ErrorEntry[];
@@ -221,7 +260,13 @@ export interface BuildCacheOptions {
221
260
  }
222
261
  interface ScannedApp {
223
262
  readonly appName: string;
263
+ readonly description?: string;
224
264
  dispatcher?: () => any;
265
+ /** Plugin definitions installed via createApp({plugins}) or .with(). */
266
+ readonly plugins?: readonly {
267
+ name: string;
268
+ $source?: SourceLocationEntry;
269
+ }[];
225
270
  container: {
226
271
  resolve?: <T = any>(name: string) => T;
227
272
  list?(): readonly {
@@ -238,6 +283,13 @@ interface ScannedApp {
238
283
  listeners: number;
239
284
  source?: SourceLocationEntry;
240
285
  }[];
286
+ listSinkStages?(): readonly {
287
+ name: string;
288
+ kind?: string;
289
+ position: "early" | "middle" | "terminal";
290
+ direction?: "outbound";
291
+ }[];
292
+ hooks?: Record<string, any>;
241
293
  };
242
294
  }
243
295
  export declare function buildCache(apps: readonly ScannedApp[], options?: BuildCacheOptions): Cache;
package/dist/scan.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * `@nwire/scan` — produces the `.nwire/` cache by inspecting booted apps.
3
3
  *
4
- * Each input must be a `ForgeApp` (or any object exposing `appName`,
5
- * `dispatcher()`, `container`, `runtime`). The scanner walks the
6
- * dispatcher metadata maps to collect actions, actors, projections,
7
- * queries, workflows, and external calls, and reads `container.list()`
8
- * for DI bindings + `runtime.listHooks()` for hooks.
4
+ * Each input is an `App` (or any object exposing `appName`, `container`,
5
+ * `runtime`). When forge is installed, the scanner walks the dispatcher
6
+ * metadata maps to collect actions, actors, projections, queries,
7
+ * workflows, and external calls. It also reads `container.list()` for
8
+ * DI bindings and `runtime.listHooks()` for hooks.
9
9
  *
10
10
  * Callers are responsible for booting their apps before scanning — the
11
11
  * forge plugin populates the dispatcher during `app.start()`.
@@ -41,6 +41,70 @@ function resolveDispatcher(app) {
41
41
  function sourceOf(value) {
42
42
  return value?.$source;
43
43
  }
44
+ /**
45
+ * Coerce a Zod schema into a JSON-Schema-shaped `{ type, properties,
46
+ * required }` value Studio's form renderer + SchemaTree understand. The
47
+ * scanner doesn't carry a Zod dep, so we walk the runtime shape Zod 4
48
+ * publishes (`def.type`, `def.shape`, `def.innerType`, etc.) and project
49
+ * the bits Studio needs.
50
+ *
51
+ * Falls back to the raw value when the shape is unrecognisable so the
52
+ * SchemaTree's "raw JSON" view still shows something.
53
+ */
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ function zodToJsonSchema(schema) {
56
+ if (!schema || typeof schema !== "object")
57
+ return schema;
58
+ const s = schema;
59
+ const def = (s.def ?? s._def ?? {});
60
+ const type = String(def.type ?? "");
61
+ switch (type) {
62
+ case "object": {
63
+ const shape = (def.shape ?? {});
64
+ const properties = {};
65
+ const required = [];
66
+ for (const [key, raw] of Object.entries(shape)) {
67
+ const field = raw;
68
+ const inner = zodToJsonSchema(field);
69
+ properties[key] = inner;
70
+ // A field is required when its def isn't `optional`/`nullable`.
71
+ const innerType = (field.def ?? {}).type;
72
+ if (innerType !== "optional" && innerType !== "nullable") {
73
+ required.push(key);
74
+ }
75
+ }
76
+ return { type: "object", properties, required };
77
+ }
78
+ case "string":
79
+ return { type: "string" };
80
+ case "number":
81
+ return { type: "number" };
82
+ case "boolean":
83
+ return { type: "boolean" };
84
+ case "literal":
85
+ return { type: typeof def.value, enum: [def.value] };
86
+ case "enum": {
87
+ const opts = (def.entries ?? def.values ?? []);
88
+ return { type: "string", enum: opts };
89
+ }
90
+ case "array":
91
+ return { type: "array", items: zodToJsonSchema(def.element) };
92
+ case "optional":
93
+ case "nullable":
94
+ case "default":
95
+ return zodToJsonSchema(def.innerType);
96
+ case "union": {
97
+ const opts = (def.options ?? []);
98
+ return { anyOf: opts.map((o) => zodToJsonSchema(o)) };
99
+ }
100
+ case "record":
101
+ return { type: "object", additionalProperties: zodToJsonSchema(def.valueType) };
102
+ default:
103
+ // Unrecognised — return the raw shape so the SchemaTree at least
104
+ // shows the JSON, even if the form renderer can't drive it.
105
+ return schema;
106
+ }
107
+ }
44
108
  function safeMap(map) {
45
109
  return map ?? new Map();
46
110
  }
@@ -48,7 +112,6 @@ export function buildCache(apps, options = {}) {
48
112
  const out = {
49
113
  generatedAt: new Date().toISOString(),
50
114
  apps: [],
51
- modules: [],
52
115
  actions: [],
53
116
  events: [],
54
117
  actors: [],
@@ -65,6 +128,7 @@ export function buildCache(apps, options = {}) {
65
128
  commands: [],
66
129
  hooks: [],
67
130
  plugins: [],
131
+ sinks: [],
68
132
  bindings: [],
69
133
  resources: [],
70
134
  errors: [],
@@ -73,7 +137,32 @@ export function buildCache(apps, options = {}) {
73
137
  };
74
138
  const eventNamesSeen = new Set();
75
139
  for (const app of apps) {
76
- out.apps.push({ name: app.appName, modules: [] });
140
+ const pluginNames = (app.plugins ?? []).map((p) => p.name);
141
+ out.apps.push({
142
+ name: app.appName,
143
+ description: app.description,
144
+ plugins: pluginNames,
145
+ });
146
+ // ── Plugins ─────────────────────────────────────────────────────
147
+ for (const plugin of app.plugins ?? []) {
148
+ out.plugins.push({
149
+ name: plugin.name,
150
+ kind: "plugin",
151
+ app: app.appName,
152
+ source: plugin.$source,
153
+ });
154
+ }
155
+ // ── Sink stages (outbound) ─────────────────────────────────────
156
+ const sinkStages = app.runtime?.listSinkStages?.() ?? [];
157
+ for (const stage of sinkStages) {
158
+ out.sinks.push({
159
+ name: stage.name,
160
+ app: app.appName,
161
+ kind: stage.kind,
162
+ position: stage.position,
163
+ direction: stage.direction ?? "outbound",
164
+ });
165
+ }
77
166
  const dispatcher = resolveDispatcher(app);
78
167
  if (!dispatcher) {
79
168
  // Plain HTTP App with no forge — the scanner has nothing to
@@ -84,20 +173,34 @@ export function buildCache(apps, options = {}) {
84
173
  const handlers = safeMap(dispatcher.handlers);
85
174
  for (const [name, handler] of handlers) {
86
175
  const action = handler.action ?? {};
176
+ const emits = (action.emits ?? []);
87
177
  out.actions.push({
88
178
  name,
89
179
  app: app.appName,
180
+ description: typeof action.description === "string" ? action.description : undefined,
90
181
  public: Boolean(action.$public),
91
- inputSchema: action.schema,
182
+ inputSchema: zodToJsonSchema(action.schema),
183
+ emits: emits.map((e) => String(e?.name ?? "")).filter(Boolean),
184
+ hasInlineHandler: typeof action.handler === "object" && action.handler !== null,
185
+ persona: typeof action.persona === "string" ? action.persona : undefined,
186
+ journeyStep: typeof action.journeyStep === "string" ? action.journeyStep : undefined,
187
+ capability: typeof action.capability === "string" ? action.capability : undefined,
188
+ slo: action.slo,
189
+ retry: action.retry,
190
+ policy: action.policy,
191
+ tags: action.tags,
92
192
  source: sourceOf(action),
93
193
  });
94
- for (const ev of (action.emits ?? [])) {
194
+ for (const ev of emits) {
95
195
  if (ev?.name && !eventNamesSeen.has(ev.name)) {
96
196
  eventNamesSeen.add(ev.name);
97
197
  out.events.push({
98
198
  name: ev.name,
99
199
  app: app.appName,
100
200
  public: Boolean(ev.$public),
201
+ description: typeof ev.description === "string" ? ev.description : undefined,
202
+ version: typeof ev.version === "number" ? ev.version : undefined,
203
+ audience: Array.isArray(ev.audience) ? ev.audience : undefined,
101
204
  source: sourceOf(ev),
102
205
  });
103
206
  }
@@ -109,19 +212,37 @@ export function buildCache(apps, options = {}) {
109
212
  // ── Actors ──────────────────────────────────────────────────────
110
213
  const actors = safeMap(dispatcher.actors);
111
214
  for (const [name, def] of actors) {
112
- out.actors.push({ name, app: app.appName, source: sourceOf(def) });
215
+ const stateNames = def.states && typeof def.states === "object"
216
+ ? Object.keys(def.states)
217
+ : [];
218
+ out.actors.push({
219
+ name,
220
+ app: app.appName,
221
+ states: stateNames,
222
+ source: sourceOf(def),
223
+ });
113
224
  }
114
225
  // ── Projections + listened events ──────────────────────────────
115
226
  const projections = safeMap(dispatcher.projections);
116
227
  for (const [name, def] of projections) {
117
- out.projections.push({ name, app: app.appName, source: sourceOf(def) });
118
- for (const ev of (def.listens ?? [])) {
228
+ const listens = (def.listens ?? []);
229
+ out.projections.push({
230
+ name,
231
+ app: app.appName,
232
+ description: typeof def.description === "string" ? def.description : undefined,
233
+ listens: listens.map((e) => String(e?.name ?? "")).filter(Boolean),
234
+ source: sourceOf(def),
235
+ });
236
+ for (const ev of listens) {
119
237
  if (ev?.name && !eventNamesSeen.has(ev.name)) {
120
238
  eventNamesSeen.add(ev.name);
121
239
  out.events.push({
122
240
  name: ev.name,
123
241
  app: app.appName,
124
242
  public: Boolean(ev.$public),
243
+ description: typeof ev.description === "string" ? ev.description : undefined,
244
+ version: typeof ev.version === "number" ? ev.version : undefined,
245
+ audience: Array.isArray(ev.audience) ? ev.audience : undefined,
125
246
  source: sourceOf(ev),
126
247
  });
127
248
  }
@@ -133,10 +254,12 @@ export function buildCache(apps, options = {}) {
133
254
  // ── Queries ─────────────────────────────────────────────────────
134
255
  const queries = safeMap(dispatcher.queries);
135
256
  for (const [name, def] of queries) {
257
+ const proj = def.projection;
136
258
  out.queries.push({
137
259
  name,
138
260
  app: app.appName,
139
261
  public: Boolean(def.$public),
262
+ projection: typeof proj?.name === "string" ? proj.name : undefined,
140
263
  source: sourceOf(def),
141
264
  });
142
265
  }
@@ -144,10 +267,15 @@ export function buildCache(apps, options = {}) {
144
267
  const workflowDefs = dispatcher.listWorkflows?.() ?? [];
145
268
  for (const def of workflowDefs) {
146
269
  const name = String(def.name);
270
+ const subscribed = (def.subscribedEvents ?? new Set());
271
+ const dispatched = (def.dispatchedActions ?? new Set());
147
272
  out.workflows.push({
148
273
  name,
149
274
  app: app.appName,
150
275
  public: Boolean(def.$public),
276
+ description: typeof def.description === "string" ? def.description : undefined,
277
+ subscribesTo: [...subscribed],
278
+ dispatches: [...dispatched],
151
279
  source: sourceOf(def),
152
280
  });
153
281
  for (const evName of (def.subscribedEvents ?? new Set())) {
@@ -173,15 +301,37 @@ export function buildCache(apps, options = {}) {
173
301
  });
174
302
  }
175
303
  // ── Hooks ──────────────────────────────────────────────────────
176
- const hooks = app.runtime?.listHooks?.() ?? [];
177
- for (const h of hooks) {
178
- out.hooks.push({
179
- id: h.id,
180
- name: h.name,
181
- chain: h.chain,
182
- listeners: h.listeners,
183
- source: h.source,
184
- });
304
+ // Prefer the structural listHooks helper if the runtime exposes one;
305
+ // otherwise walk `runtime.hooks` map and read step counts off each
306
+ // hook. Framework slots that haven't been materialised by a plugin
307
+ // (`runtime.defineHook(name)`) are absent — that's correct, we don't
308
+ // want to surface dead slots.
309
+ const explicit = app.runtime?.listHooks?.();
310
+ if (explicit) {
311
+ for (const h of explicit) {
312
+ out.hooks.push({
313
+ id: h.id,
314
+ name: h.name,
315
+ chain: h.chain,
316
+ listeners: h.listeners,
317
+ source: h.source,
318
+ });
319
+ }
320
+ }
321
+ else if (app.runtime?.hooks) {
322
+ const hookMap = app.runtime.hooks;
323
+ for (const [slotName, hookValue] of Object.entries(hookMap)) {
324
+ if (!hookValue || typeof hookValue !== "object")
325
+ continue;
326
+ const h = hookValue;
327
+ const counts = typeof h.stepCounts === "function" ? h.stepCounts() : { chain: 0, listeners: 0 };
328
+ out.hooks.push({
329
+ id: h.id ?? slotName,
330
+ name: slotName,
331
+ chain: counts.chain,
332
+ listeners: counts.listeners,
333
+ });
334
+ }
185
335
  }
186
336
  }
187
337
  // ── Routes (from passed-in interfaces) ────────────────────────────
@@ -198,13 +348,23 @@ export function buildCache(apps, options = {}) {
198
348
  for (const r of options.resources ?? []) {
199
349
  if ("definition" in r) {
200
350
  const def = r.definition;
201
- out.resources.push({ name: String(def?.name ?? ""), app: r.app, module: r.module, source: sourceOf(def) });
351
+ out.resources.push({
352
+ name: String(def?.name ?? ""),
353
+ app: r.app,
354
+ module: r.module,
355
+ source: sourceOf(def),
356
+ });
202
357
  }
203
358
  }
204
359
  for (const e of options.errors ?? []) {
205
360
  if ("definition" in e) {
206
361
  const def = e.definition;
207
- out.errors.push({ code: String(def?.code ?? ""), app: e.app, module: e.module, source: sourceOf(def) });
362
+ out.errors.push({
363
+ code: String(def?.code ?? ""),
364
+ app: e.app,
365
+ module: e.module,
366
+ source: sourceOf(def),
367
+ });
208
368
  }
209
369
  }
210
370
  for (const m of options.middleware ?? []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/scan",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Nwire — system registry scanner. Walks AppDefinition[] manifests and writes the .nwire/ cache (actions, events, actors, projections, queries, modules, routes, event graph). Vite plugin + standalone function.",
5
5
  "keywords": [
6
6
  "cache",
@@ -33,16 +33,16 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "zod": "^4.4.3",
36
- "@nwire/hooks": "0.10.1",
37
- "@nwire/messages": "0.10.1",
38
- "@nwire/forge": "0.10.1"
36
+ "@nwire/forge": "0.11.1",
37
+ "@nwire/messages": "0.11.1",
38
+ "@nwire/hooks": "0.11.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^22.19.9",
42
42
  "typescript": "^5.9.3",
43
43
  "vite": "npm:rolldown-vite@latest",
44
44
  "vitest": "^4.0.18",
45
- "@nwire/container": "0.10.1"
45
+ "@nwire/container": "0.11.1"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsc && node ../../scripts/fix-dist-extensions.mjs dist",