@fuzdev/fuz_app 0.47.0 → 0.49.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.
@@ -10,12 +10,12 @@ import { ActionRegistry } from './action_registry.js';
10
10
  export const COMPOSABLE_ACTION_METHODS = ['heartbeat', 'cancel'];
11
11
  const COMPOSABLE_METHOD_SET = new Set(COMPOSABLE_ACTION_METHODS);
12
12
  /**
13
- * Type predicate for filtering composable methods out of a typed `ActionsApi`
13
+ * Type predicate for filtering composable methods out of a typed `FrontendActionsApi`
14
14
  * `method_filter`. Avoids the `(... as never)` cast required to call
15
15
  * `Array.prototype.includes` on the readonly tuple at narrow string types.
16
16
  *
17
17
  * @example
18
- * generate_actions_api(specs, imports, {
18
+ * generate_frontend_actions_api(specs, imports, {
19
19
  * method_filter: (s) => !is_composable_action_method(s.method),
20
20
  * });
21
21
  */
@@ -312,10 +312,10 @@ export const to_action_spec_identifier = (method) => `${method}_action_spec`;
312
312
  export const to_action_spec_input_identifier = (method) => `${to_action_spec_identifier(method)}.input`;
313
313
  export const to_action_spec_output_identifier = (method) => `${to_action_spec_identifier(method)}.output`;
314
314
  /**
315
- * Generates one method line of the typed `ActionsApi` interface for a single
316
- * spec. Encapsulates the input/options/return-type signature shape so the
317
- * surface evolves in one place when fields like `signal` or `transport_name`
318
- * are added to per-call options.
315
+ * Generates one method line of the typed `FrontendActionsApi` interface for a
316
+ * single spec. Encapsulates the input/options/return-type signature shape so
317
+ * the surface evolves in one place when fields like `signal` or
318
+ * `transport_name` are added to per-call options.
319
319
  *
320
320
  * Async methods (`request_response`, `remote_notification`, async
321
321
  * `local_call`) get an optional second `options?: RpcClientCallOptions` arg
@@ -336,7 +336,7 @@ export const to_action_spec_output_identifier = (method) => `${to_action_spec_id
336
336
  * @param options.sync_returns_value - when true (default), sync local_call
337
337
  * methods return the output value directly; when false they're wrapped in
338
338
  * `Result<{value, error}>` like async methods. Set to `false` if your
339
- * ActionsApi treats every method uniformly.
339
+ * FrontendActionsApi treats every method uniformly.
340
340
  * @returns one line like `foo: (input: ActionInputs['foo'], options?: RpcClientCallOptions) => Promise<Result<...>>;`
341
341
  */
342
342
  export const generate_actions_api_method_signature = (spec, options) => {
@@ -356,6 +356,40 @@ export const generate_actions_api_method_signature = (spec, options) => {
356
356
  : result_return;
357
357
  return `${spec.method}: (${input_param}${options_param}) => ${return_type};`;
358
358
  };
359
+ // --------------------------------------------------------------------------
360
+ // High-level codegen helpers — compose the lower-level primitives above into
361
+ // the literal blocks consumer `*.gen.ts` producers emit. Tier 1 consumers
362
+ // (HTTP-only, e.g. tx) call the value-side helpers; Tier 2 (`TypedActionEvent`-
363
+ // aware, e.g. zzz) also call the typed-event + frontend-handlers helpers.
364
+ //
365
+ // **Multi-source consumers.** Helpers that reference specs at runtime
366
+ // (`generate_action_specs_record`, `generate_action_inputs_outputs`,
367
+ // `generate_backend_actions_api`) default to a single `* as specs from
368
+ // specs_module` namespace import and emit `specs.{method}_action_spec`. Pass
369
+ // `qualify_spec?: (spec) => string` to emit a per-spec qualified identifier
370
+ // (e.g. `admin_specs.account_list_action_spec`) for consumers stitching local
371
+ // specs together with multiple upstream sources (`all_admin_action_specs` /
372
+ // `all_permit_offer_action_specs` / `all_account_action_specs` /
373
+ // `all_self_service_role_action_specs` from fuz_app). When `qualify_spec` is
374
+ // set, the helper does NOT add a `* as specs` import — the consumer manages
375
+ // the multiple `* as ns` imports itself — and `specs_module` is ignored.
376
+ // `create_namespace_qualifier` automates the source-table → qualifier wiring.
377
+ // --------------------------------------------------------------------------
378
+ /**
379
+ * Format a `z.enum([...])` runtime const + matching `z.infer` type alias.
380
+ * Caller is responsible for ensuring `methods` is non-empty (`z.enum([])` is
381
+ * invalid) and registering the `zod` import on the `ImportBuilder`.
382
+ */
383
+ const format_method_enum_block = (name, jsdoc, methods) => {
384
+ const lines = methods.map((m) => `\t'${m}',`).join('\n');
385
+ return `/**
386
+ * ${jsdoc}
387
+ */
388
+ export const ${name} = z.enum([
389
+ ${lines}
390
+ ]);
391
+ export type ${name} = z.infer<typeof ${name}>;`;
392
+ };
359
393
  /** Default emit set — every enum kind. */
360
394
  export const ACTION_METHOD_ENUM_KINDS_ALL = new Set([
361
395
  'all',
@@ -364,19 +398,41 @@ export const ACTION_METHOD_ENUM_KINDS_ALL = new Set([
364
398
  'local_call',
365
399
  'frontend',
366
400
  'backend',
401
+ 'frontend_handled',
402
+ 'backend_handled',
403
+ 'broadcast',
367
404
  ]);
368
405
  /**
369
406
  * Filter `heartbeat` / `cancel` out of `specs` unless the consumer opts back in.
370
407
  * Composables ship from fuz_app and are spread into every consumer's `actions`
371
408
  * array at registration time — they should not appear in consumer-owned typed
372
- * surfaces (`ActionMethod`, `ActionsApi`, `ActionInputs`, etc.) by default.
409
+ * surfaces (`ActionMethod`, `FrontendActionsApi`, `ActionInputs`, etc.) by
410
+ * default.
373
411
  */
374
412
  const filter_composables = (specs, include_composables) => include_composables ? specs : specs.filter((s) => !is_composable_action_method(s.method));
413
+ /**
414
+ * Resolve the per-spec identifier qualifier used by the multi-source helpers
415
+ * (`generate_action_specs_record`, `generate_action_inputs_outputs`,
416
+ * `generate_backend_actions_api`). When `qualify_spec` is set, returns the
417
+ * caller's callback verbatim — the consumer is managing its own namespace
418
+ * imports. Otherwise, registers the default `* as specs from specs_module`
419
+ * import (defaulting to `'./action_specs.js'`) and returns the matching
420
+ * `specs.${method}_action_spec` qualifier.
421
+ */
422
+ const resolve_spec_qualifier = (imports, options) => {
423
+ if (options?.qualify_spec)
424
+ return options.qualify_spec;
425
+ const specs_module = options?.specs_module ?? DEFAULT_SPECS_MODULE;
426
+ imports.add(specs_module, '* as specs');
427
+ return (s) => `specs.${s.method}_action_spec`;
428
+ };
375
429
  /**
376
430
  * Emit one or more `z.enum([...])` declarations for action method names —
377
431
  * `ActionMethod`, `RequestResponseActionMethod`, `RemoteNotificationActionMethod`,
378
- * `LocalCallActionMethod`, `FrontendActionMethod`, `BackendActionMethod`. Pairs
379
- * each runtime const with a `z.infer` type alias under the same identifier.
432
+ * `LocalCallActionMethod`, `FrontendActionMethod`, `BackendActionMethod`,
433
+ * `FrontendRequestResponseMethod`, `BackendRequestResponseMethod`,
434
+ * `BroadcastActionMethod`. Pairs each runtime const with a `z.infer` type
435
+ * alias under the same identifier.
380
436
  *
381
437
  * Composable methods (`heartbeat`, `cancel`) are filtered out by default —
382
438
  * pass `include_composables: true` if a consumer genuinely wants them on
@@ -386,7 +442,11 @@ const filter_composables = (specs, include_composables) => include_composables ?
386
442
  * Adds `import {z} from 'zod';` to `imports` only when at least one block
387
443
  * is emitted (idempotent).
388
444
  *
389
- * @param options.emit - subset of enums to emit; defaults to all six.
445
+ * For genuinely cross-product enums the discriminator doesn't cover, use
446
+ * `generate_action_method_enum_block` — caller owns the predicate, name,
447
+ * and jsdoc.
448
+ *
449
+ * @param options.emit - subset of enums to emit; defaults to all nine.
390
450
  * @param options.include_composables - when true, retains `heartbeat` /
391
451
  * `cancel` in the emitted enums. Default `false`.
392
452
  */
@@ -402,21 +462,48 @@ export const generate_action_method_enums = (specs, imports, options) => {
402
462
  // Consumers that need a kind to exist should check their spec set, not the helper.
403
463
  if (methods.length === 0)
404
464
  return;
405
- const lines = methods.map((m) => `\t'${m}',`).join('\n');
406
- blocks.push(`/**\n * ${jsdoc}\n */\nexport const ${name} = z.enum([\n${lines}\n]);
407
- export type ${name} = z.infer<typeof ${name}>;`);
465
+ blocks.push(format_method_enum_block(name, jsdoc, methods));
408
466
  };
409
467
  emit_block('all', 'ActionMethod', registry.methods, 'All action method names. Request/response actions have two types per method.');
410
468
  emit_block('request_response', 'RequestResponseActionMethod', registry.request_response_methods, 'Names of all request_response actions.');
411
469
  emit_block('remote_notification', 'RemoteNotificationActionMethod', registry.remote_notification_methods, 'Names of all remote_notification actions.');
412
470
  emit_block('local_call', 'LocalCallActionMethod', registry.local_call_methods, 'Names of all local_call actions.');
413
- emit_block('frontend', 'FrontendActionMethod', registry.frontend_methods, 'Names of all actions that may be handled on the client.');
414
- emit_block('backend', 'BackendActionMethod', registry.backend_methods, 'Names of all actions that may be handled on the server.');
471
+ // Loose: every spec the side might encounter (call, receive, or execute).
472
+ // Drives the typed-Proxy method enum keyed by FrontendActionsApi.
473
+ emit_block('frontend', 'FrontendActionMethod', registry.methods_relevant_to_frontend, 'Names of all actions in the typed FrontendActionsApi surface — every spec the frontend may encounter (call, receive, or execute locally).');
474
+ emit_block('backend', 'BackendActionMethod', registry.methods_relevant_to_backend, 'Names of all actions the backend may encounter — request_response and remote_notification (local_call is frontend-only).');
475
+ // Narrow: request_response actions this side handles (receives).
476
+ emit_block('frontend_handled', 'FrontendRequestResponseMethod', registry.frontend_handled_methods, 'Names of request_response actions the frontend handles (initiator excludes frontend).');
477
+ emit_block('backend_handled', 'BackendRequestResponseMethod', registry.backend_handled_methods, 'Names of request_response actions the backend handles (initiator excludes backend).');
478
+ // Broadcast: backend-initiated remote_notification, excluding `streams` targets.
479
+ emit_block('broadcast', 'BroadcastActionMethod', registry.broadcast_methods, "Names of remote_notification actions exposed by the broadcast API (backend-initiated, excluding request-scoped progress notifications named by another action's `streams`).");
415
480
  if (blocks.length === 0)
416
481
  return '';
417
482
  imports.add('zod', 'z');
418
483
  return blocks.join('\n\n');
419
484
  };
485
+ /**
486
+ * Emit a single named `z.enum([...])` + `z.infer` block for an arbitrary
487
+ * spec subset. Lower-level escape hatch from `generate_action_method_enums` —
488
+ * for cross-product or domain-specific enums the built-in discriminator
489
+ * doesn't cover.
490
+ *
491
+ * Mirrors the built-in helper's contract: composables filtered by default,
492
+ * empty subsets return `''` (skip rather than emit `z.enum([])`), `zod`
493
+ * import registered idempotently only when at least one method qualifies.
494
+ *
495
+ * The cross-product space is open-ended; rather than grow the
496
+ * `ActionMethodEnumKind` discriminator one cross-product at a time, callers
497
+ * own the subset shape — name, jsdoc, predicate.
498
+ */
499
+ export const generate_action_method_enum_block = (specs, imports, options) => {
500
+ const filtered = filter_composables(specs, options.include_composables);
501
+ const methods = filtered.filter(options.predicate).map((s) => s.method);
502
+ if (methods.length === 0)
503
+ return '';
504
+ imports.add('zod', 'z');
505
+ return format_method_enum_block(options.name, options.jsdoc, methods);
506
+ };
420
507
  /**
421
508
  * Emit the fixed-shape `TypedActionEvent` alias used by `FrontendActionHandlers`
422
509
  * to narrow `ActionEvent.data` against the consumer's generated `ActionEventDatas`
@@ -449,11 +536,12 @@ type TypedActionEvent<
449
536
  * Array<ActionSpecUnion>` value bundling every spec. Adds the `* as specs`
450
537
  * namespace import + the `ActionSpecUnion` type import.
451
538
  *
452
- * **Single-namespace.** Every spec is referenced as `specs.{method}_action_spec`.
453
- * Multi-source consumers (tx, visiones) need a per-method namespace lookup
454
- * and currently use lower-level primitives instead see the `--- High-level
455
- * codegen helpers ---` banner above for the rationale and the planned
456
- * `qualify_spec?` extension point.
539
+ * @param options.qualify_spec - per-spec qualified identifier callback for
540
+ * multi-source consumers (e.g. ``(s) => `admin_specs.${s.method}_action_spec` ``).
541
+ * When set, the helper emits the callback's return value instead of
542
+ * ``specs.${method}_action_spec`` and skips the default `* as specs`
543
+ * import the consumer manages its own namespace imports. `specs_module`
544
+ * is ignored when `qualify_spec` is set. Single-source consumers omit it.
457
545
  */
458
546
  export const generate_action_specs_record = (specs, imports, options) => {
459
547
  const filtered = filter_composables(specs, options?.include_composables);
@@ -470,14 +558,9 @@ export interface ActionSpecs {}
470
558
 
471
559
  export const action_specs: Array<ActionSpecUnion> = Object.values(ActionSpecs);`;
472
560
  }
473
- const specs_module = options?.specs_module ?? DEFAULT_SPECS_MODULE;
474
- imports.add(specs_module, '* as specs');
475
- const value_lines = filtered
476
- .map((s) => `\t${s.method}: specs.${s.method}_action_spec,`)
477
- .join('\n');
478
- const type_lines = filtered
479
- .map((s) => `\t${s.method}: typeof specs.${s.method}_action_spec;`)
480
- .join('\n');
561
+ const qualify = resolve_spec_qualifier(imports, options);
562
+ const value_lines = filtered.map((s) => `\t${s.method}: ${qualify(s)},`).join('\n');
563
+ const type_lines = filtered.map((s) => `\t${s.method}: typeof ${qualify(s)};`).join('\n');
481
564
  return `/**
482
565
  * Action specifications indexed by method name.
483
566
  * These represent the complete action spec definitions.
@@ -498,8 +581,11 @@ export const action_specs: Array<ActionSpecUnion> = Object.values(ActionSpecs);`
498
581
  *
499
582
  * Adds `import {z} from 'zod';` and the `* as specs` namespace import.
500
583
  *
501
- * **Single-namespace.** Same caveat as `generate_action_specs_record`
502
- * multi-source consumers use the lower-level primitives.
584
+ * @param options.qualify_spec - per-spec qualified identifier callback for
585
+ * multi-source consumers. The helper appends `.input` / `.output` to the
586
+ * callback's return value. When set, the helper skips the default
587
+ * `* as specs` import — the consumer manages its own namespace imports —
588
+ * and `specs_module` is ignored. Single-source consumers omit it.
503
589
  */
504
590
  export const generate_action_inputs_outputs = (specs, imports, options) => {
505
591
  const filtered = filter_composables(specs, options?.include_composables);
@@ -523,19 +609,14 @@ export const ActionOutputs = {} as const;
523
609
  export interface ActionOutputs {}`;
524
610
  }
525
611
  imports.add('zod', 'z');
526
- const specs_module = options?.specs_module ?? DEFAULT_SPECS_MODULE;
527
- imports.add(specs_module, '* as specs');
528
- const inputs_value = filtered
529
- .map((s) => `\t${s.method}: specs.${s.method}_action_spec.input,`)
530
- .join('\n');
612
+ const qualify = resolve_spec_qualifier(imports, options);
613
+ const inputs_value = filtered.map((s) => `\t${s.method}: ${qualify(s)}.input,`).join('\n');
531
614
  const inputs_type = filtered
532
- .map((s) => `\t${s.method}: z.infer<typeof specs.${s.method}_action_spec.input>;`)
533
- .join('\n');
534
- const outputs_value = filtered
535
- .map((s) => `\t${s.method}: specs.${s.method}_action_spec.output,`)
615
+ .map((s) => `\t${s.method}: z.infer<typeof ${qualify(s)}.input>;`)
536
616
  .join('\n');
617
+ const outputs_value = filtered.map((s) => `\t${s.method}: ${qualify(s)}.output,`).join('\n');
537
618
  const outputs_type = filtered
538
- .map((s) => `\t${s.method}: z.infer<typeof specs.${s.method}_action_spec.output>;`)
619
+ .map((s) => `\t${s.method}: z.infer<typeof ${qualify(s)}.output>;`)
539
620
  .join('\n');
540
621
  return `/**
541
622
  * Action parameter schemas indexed by method name.
@@ -570,12 +651,16 @@ ${outputs_type}
570
651
  *
571
652
  * Adds the per-kind data type imports (only the kinds that appear in `specs`).
572
653
  *
573
- * @param options.collections_path - when set, adds `ActionInputs` /
574
- * `ActionOutputs` type imports from this path. Leave unset (default) when
575
- * the producer emits `ActionEventDatas` in the same file as
576
- * `ActionInputs` / `ActionOutputs` same-file scope means no imports
577
- * needed (the zzz pattern, where `generate_action_inputs_outputs` and
578
- * this helper feed the same `action_collections.ts` output).
654
+ * @param options.same_file - when `true` (default), assumes `ActionInputs` /
655
+ * `ActionOutputs` are in the same module as the emitted `ActionEventDatas`
656
+ * and adds no import (the zzz pattern, where `generate_action_inputs_outputs`
657
+ * and this helper feed the same `action_collections.ts` output). When
658
+ * `false`, adds `ActionInputs` / `ActionOutputs` type imports from
659
+ * `collections_path`.
660
+ * @param options.collections_path - import path used when `same_file: false`.
661
+ * Defaults to `'./action_collections.js'`. Ignored when `same_file: true`
662
+ * — `same_file` is the file-layout switch; `collections_path` is just the
663
+ * path the import resolves to.
579
664
  */
580
665
  export const generate_action_event_datas = (specs, imports, options) => {
581
666
  const filtered = filter_composables(specs, options?.include_composables);
@@ -589,8 +674,10 @@ export const generate_action_event_datas = (specs, imports, options) => {
589
674
  */
590
675
  export interface ActionEventDatas {}`;
591
676
  }
592
- if (options?.collections_path) {
593
- imports.add_types(options.collections_path, 'ActionInputs', 'ActionOutputs');
677
+ const same_file = options?.same_file ?? true;
678
+ if (!same_file) {
679
+ const collections_path = options?.collections_path ?? DEFAULT_COLLECTIONS_PATH;
680
+ imports.add_types(collections_path, 'ActionInputs', 'ActionOutputs');
594
681
  }
595
682
  const lines = filtered.map((spec) => {
596
683
  const data_type = spec.kind === 'request_response'
@@ -614,29 +701,36 @@ ${lines.join('\n')}
614
701
  }`;
615
702
  };
616
703
  /**
617
- * Emit the `ActionsApi` interface — one method signature per spec via
704
+ * Emit the `FrontendActionsApi` interface — one method signature per spec via
618
705
  * `generate_actions_api_method_signature`. Optionally filter the spec set
619
706
  * (e.g. omit composable methods) via `method_filter`.
620
707
  *
621
708
  * Adds the `Result`, `JsonrpcErrorObject`, and `RpcClientCallOptions` type
622
709
  * imports plus `ActionInputs` / `ActionOutputs` (sourced from `collections_path`).
710
+ *
711
+ * The interface name is fixed at `FrontendActionsApi` — the symmetric counterpart
712
+ * of `BackendActionsApi`. Earlier consumer-named variants (`MyActionsApi`,
713
+ * `VisionesActionsApi`) were retired in API review III to make the side-of-the-wire
714
+ * intent visible at every call site. If a consumer needs a different name they
715
+ * hand-roll the interface (the helper's job is the standard symmetric shape).
623
716
  */
624
- export const generate_actions_api = (specs, imports, options) => {
717
+ export const generate_frontend_actions_api = (specs, imports, options) => {
625
718
  const composable_filtered = filter_composables(specs, options?.include_composables);
626
719
  const filter = options?.method_filter;
627
720
  const filtered = filter ? composable_filtered.filter((s) => filter(s)) : composable_filtered;
628
721
  const interface_doc = `/**
629
- * Interface for action dispatch functions.
630
- * Async methods (request_response, remote_notification, async local_call)
631
- * return \`Promise<Result<...>>\` and accept an optional \`RpcClientCallOptions\`
632
- * second arg that threads \`signal\`, \`transport_name\`, and \`queue\` through to
633
- * the peer. Sync local_call methods return values directly.
722
+ * Typed dispatch surface for the frontend's RPC client. Symmetric counterpart
723
+ * of \`BackendActionsApi\`. Async methods (request_response, remote_notification,
724
+ * async local_call) return \`Promise<Result<...>>\` and accept an optional
725
+ * \`RpcClientCallOptions\` second arg that threads \`signal\`, \`transport_name\`,
726
+ * and \`queue\` through to the peer. Sync local_call methods return values
727
+ * directly.
634
728
  */`;
635
729
  if (filtered.length === 0) {
636
- // Empty spec list — emit `ActionsApi {}` and skip every import. None
637
- // of the symbols would be referenced by the empty body.
730
+ // Empty spec list — emit `FrontendActionsApi {}` and skip every import.
731
+ // None of the symbols would be referenced by the empty body.
638
732
  return `${interface_doc}
639
- export interface ActionsApi {}`;
733
+ export interface FrontendActionsApi {}`;
640
734
  }
641
735
  const collections_path = options?.collections_path ?? DEFAULT_COLLECTIONS_PATH;
642
736
  imports.add_type('@fuzdev/fuz_util/result.js', 'Result');
@@ -650,7 +744,7 @@ export interface ActionsApi {}`;
650
744
  .map((line) => `\t${line}`)
651
745
  .join('\n');
652
746
  return `${interface_doc}
653
- export interface ActionsApi {
747
+ export interface FrontendActionsApi {
654
748
  ${lines}
655
749
  }`;
656
750
  };
@@ -696,23 +790,42 @@ ${lines};
696
790
  * each `(input) => Promise<void>`. The array bundles the matching specs as a
697
791
  * `ReadonlyArray<ActionSpecUnion>`.
698
792
  *
699
- * Filter: `kind === 'remote_notification' && initiator !== 'frontend'`.
793
+ * Filter: `kind === 'remote_notification' && initiator !== 'frontend'`,
794
+ * additionally excluding methods that are the target of another spec's
795
+ * `streams` field. Streams targets (e.g. `completion_progress`,
796
+ * `ollama_progress`) are request-scoped notifications invoked via
797
+ * `ctx.notify` inside their parent handler — they're never callable through
798
+ * the broadcast API. The discriminator is `ActionSpec.streams`, not a manual
799
+ * exclusion list.
700
800
  *
701
801
  * Adds the `* as specs` namespace import (from `specs_module`), the
702
802
  * `ActionInputs` type import (from `collections_path`), and the
703
803
  * `ActionSpecUnion` type import.
704
804
  *
705
- * **Single-namespace.** Same caveat as `generate_action_specs_record`
706
- * multi-source consumers use the lower-level primitives.
805
+ * Method signature shape today is `(input) => Promise<void>` matches the
806
+ * fire-and-forget runtime of `create_broadcast_api`. Generalizing per-kind
807
+ * via `generate_actions_api_method_signature` is deferred until a second
808
+ * backend runtime constructor lands (see SAES quest § API review III).
809
+ *
810
+ * @param options.qualify_spec - per-spec qualified identifier callback for
811
+ * multi-source consumers. When set, the helper emits the callback's return
812
+ * value instead of ``specs.${method}_action_spec`` in the broadcast array
813
+ * and skips the default `* as specs` import — the consumer manages its own
814
+ * namespace imports. `specs_module` is ignored when `qualify_spec` is set.
815
+ * Single-source consumers omit it.
707
816
  */
708
817
  export const generate_backend_actions_api = (specs, imports, options) => {
709
818
  const composable_filtered = filter_composables(specs, options?.include_composables);
710
- const broadcast = composable_filtered.filter((s) => s.kind === 'remote_notification' && s.initiator !== 'frontend');
819
+ const registry = new ActionRegistry([...composable_filtered]);
820
+ const broadcast = registry.broadcast_specs;
711
821
  imports.add_type('@fuzdev/fuz_app/actions/action_spec.js', 'ActionSpecUnion');
712
822
  const interface_doc = `/**
713
- * Broadcast-style notifications from the backend to all connected clients.
714
- * Request-scoped streaming goes through \`ctx.notify\` instead — it's
715
- * socket-scoped, not a broadcast.
823
+ * Typed dispatch surface for backend-initiated calls. Symmetric counterpart
824
+ * of \`FrontendActionsApi\`. Today exposes broadcast-style \`remote_notification\`
825
+ * methods (1→N fan-out via \`create_broadcast_api\`); request-scoped streaming
826
+ * goes through \`ctx.notify\` inside a handler — it's socket-scoped, not a
827
+ * broadcast. Will widen when a second backend runtime constructor (targeted
828
+ * send, backend-initiated request_response) lands.
716
829
  */`;
717
830
  if (broadcast.length === 0) {
718
831
  // No backend-initiated remote_notifications — skip `* as specs` and
@@ -722,18 +835,156 @@ export interface BackendActionsApi {}
722
835
 
723
836
  export const broadcast_action_specs: ReadonlyArray<ActionSpecUnion> = [];`;
724
837
  }
725
- const specs_module = options?.specs_module ?? DEFAULT_SPECS_MODULE;
726
838
  const collections_path = options?.collections_path ?? DEFAULT_COLLECTIONS_PATH;
727
- imports.add(specs_module, '* as specs');
728
839
  imports.add_type(collections_path, 'ActionInputs');
840
+ const qualify = resolve_spec_qualifier(imports, options);
729
841
  const interface_body = '\n' +
730
842
  broadcast
731
843
  .map((s) => `\t${s.method}: (input: ActionInputs['${s.method}']) => Promise<void>;`)
732
844
  .join('\n') +
733
845
  '\n';
734
- const array_body = '\n' + broadcast.map((s) => `\tspecs.${s.method}_action_spec,`).join('\n') + '\n';
846
+ const array_body = '\n' + broadcast.map((s) => `\t${qualify(s)},`).join('\n') + '\n';
735
847
  return `${interface_doc}
736
848
  export interface BackendActionsApi {${interface_body}}
737
849
 
738
850
  export const broadcast_action_specs: ReadonlyArray<ActionSpecUnion> = [${array_body}];`;
739
851
  };
852
+ /**
853
+ * Emit the `BackendActionHandlers` mapped type — one entry per
854
+ * `BackendRequestResponseMethod`, each `(input, ctx) => output | Promise<output>`.
855
+ * Replaces the hand-maintained `Exclude<>` + parallel mapped-type pattern
856
+ * (zzz had this at `zzz/src/lib/server/zzz_action_handlers.ts:42-66`).
857
+ *
858
+ * The context type is consumer-defined (e.g. zzz's `ZzzHandlerContext`). Pass
859
+ * `context_type` to name it; the helper assumes it's importable or defined
860
+ * in the emitted module's scope (consumer's responsibility).
861
+ *
862
+ * Adds `ActionInputs` / `ActionOutputs` type imports from `collections_path`
863
+ * and the `BackendRequestResponseMethod` import from `metatypes_path`.
864
+ *
865
+ * @param options.type_name - default `'BackendActionHandlers'`.
866
+ * @param options.method_enum_name - default `'BackendRequestResponseMethod'`.
867
+ * Pair with `generate_action_method_enums` emitting the `'backend_handled'` kind.
868
+ * @param options.context_type - default `'BackendHandlerContext'`. Caller's
869
+ * handler context type — must be in scope at the emit site.
870
+ * @param options.collections_path - default `'./action_collections.js'`.
871
+ * @param options.metatypes_path - default `'./action_metatypes.js'`.
872
+ */
873
+ export const generate_backend_action_handlers_map = (imports, options) => {
874
+ const type_name = options?.type_name ?? 'BackendActionHandlers';
875
+ const method_enum_name = options?.method_enum_name ?? 'BackendRequestResponseMethod';
876
+ const context_type = options?.context_type ?? 'BackendHandlerContext';
877
+ const collections_path = options?.collections_path ?? DEFAULT_COLLECTIONS_PATH;
878
+ const metatypes_path = options?.metatypes_path ?? DEFAULT_METATYPES_PATH;
879
+ imports.add_types(collections_path, 'ActionInputs', 'ActionOutputs');
880
+ imports.add_type(metatypes_path, method_enum_name);
881
+ return `/**
882
+ * Typed handler map for request_response actions the backend handles.
883
+ * One entry per ${method_enum_name}; each handler receives the typed input
884
+ * and returns the typed output (sync or async).
885
+ */
886
+ export type ${type_name} = {
887
+ [K in ${method_enum_name}]: (
888
+ input: ActionInputs[K],
889
+ ctx: ${context_type},
890
+ ) => ActionOutputs[K] | Promise<ActionOutputs[K]>;
891
+ };`;
892
+ };
893
+ /**
894
+ * Multi-source consumer helper. Takes a list of `{ns, module, specs}` rows,
895
+ * registers `import * as ns from module` for each on `imports`, builds the
896
+ * `method_to_ns` lookup with duplicate-method detection, and returns
897
+ * `{qualify_spec, all_specs}` ready to thread through the high-level
898
+ * helpers.
899
+ *
900
+ * Closes the per-file boilerplate gap that kept tx + visiones on hand-rolled
901
+ * template strings even after `qualify_spec?` landed in API review II — the
902
+ * per-call callback wasn't enough; the import dance + dup-check was the
903
+ * real boilerplate.
904
+ *
905
+ * @example
906
+ * ```ts
907
+ * const sources = [
908
+ * {ns: 'tx_specs', module: './action_specs.js', specs: all_tx_action_specs},
909
+ * {ns: 'admin_specs', module: '@fuzdev/fuz_app/auth/admin_action_specs.js', specs: all_admin_action_specs},
910
+ * ];
911
+ *
912
+ * export const gen: Gen = ({origin_path}) => {
913
+ * const imports = new ImportBuilder();
914
+ * const {qualify_spec, all_specs} = create_namespace_qualifier(sources, imports);
915
+ * return compose_gen_file({
916
+ * origin_path,
917
+ * imports,
918
+ * blocks: [
919
+ * generate_action_specs_record(all_specs, imports, {qualify_spec}),
920
+ * generate_action_inputs_outputs(all_specs, imports, {qualify_spec}),
921
+ * ],
922
+ * });
923
+ * };
924
+ * ```
925
+ *
926
+ * @throws if two sources contain the same method name (same-method detection
927
+ * is the consumer's primary debugging signal).
928
+ */
929
+ export const create_namespace_qualifier = (sources, imports) => {
930
+ const method_to_ns = new Map();
931
+ const all_specs = [];
932
+ for (const { ns, module, specs } of sources) {
933
+ imports.add(module, `* as ${ns}`);
934
+ for (const spec of specs) {
935
+ if (method_to_ns.has(spec.method)) {
936
+ throw new Error(`duplicate action method across sources: ${spec.method} (in ${method_to_ns.get(spec.method)} and ${ns})`);
937
+ }
938
+ method_to_ns.set(spec.method, ns);
939
+ all_specs.push(spec);
940
+ }
941
+ }
942
+ const qualify_spec = (spec) => {
943
+ const ns = method_to_ns.get(spec.method);
944
+ if (!ns) {
945
+ throw new Error(`unknown action method passed to qualify_spec: ${spec.method} — not in any registered source`);
946
+ }
947
+ return `${ns}.${spec.method}_action_spec`;
948
+ };
949
+ return { qualify_spec, all_specs };
950
+ };
951
+ /**
952
+ * Wrap the per-`*.gen.ts` boilerplate (banner + `imports.build()` +
953
+ * blocks join + template literal) into one call. Returns the full file body
954
+ * as a string ready to return from a `Gen` function.
955
+ *
956
+ * Each consumer producer collapses to one `compose_gen_file` call wrapping
957
+ * the helper invocations.
958
+ *
959
+ * @example
960
+ * ```ts
961
+ * export const gen: Gen = ({origin_path}) => {
962
+ * const imports = new ImportBuilder();
963
+ * return compose_gen_file({
964
+ * origin_path,
965
+ * imports,
966
+ * blocks: [
967
+ * generate_action_specs_record(all_action_specs, imports),
968
+ * generate_action_inputs_outputs(all_action_specs, imports),
969
+ * generate_action_event_datas(all_action_specs, imports),
970
+ * ],
971
+ * });
972
+ * };
973
+ * ```
974
+ *
975
+ * Empty blocks (`''`) are filtered out so helpers that short-circuit on
976
+ * empty spec sets don't introduce stray double blank lines.
977
+ */
978
+ export const compose_gen_file = (input) => {
979
+ const banner = create_banner(input.origin_path);
980
+ const body = input.blocks.filter(Boolean).join('\n\n');
981
+ return `
982
+ // ${banner}
983
+
984
+ ${input.imports.build()}
985
+
986
+ ${body}
987
+
988
+ // ${banner}
989
+ `;
990
+ };
@@ -1,6 +1,29 @@
1
1
  /**
2
2
  * `ActionRegistry` — query and filter utility over `ActionSpecUnion[]`.
3
3
  *
4
+ * Vocabulary (set in API review III, see the `docs/` directory and the SAES quest):
5
+ * - `*_handled_*` — request_response specs the named side **receives**
6
+ * (so the named side owns the handler). Used by codegen to emit typed
7
+ * handler maps.
8
+ * - `*_relevant_to_*` — the loose "everything this side might encounter"
9
+ * set, used by the typed-Proxy method enums (`FrontendActionMethod`,
10
+ * `BackendActionMethod`).
11
+ * - `broadcast_*` — kind-narrow `remote_notification` set with the
12
+ * `streams`-target exclusion. Today this matches what the broadcast
13
+ * API exposes.
14
+ * - `backend_initiated_*` — forward-looking kind-agnostic version of the
15
+ * broadcast set. Same content today; will diverge when local_calls or
16
+ * backend `request_response` join the backend's typed surface.
17
+ *
18
+ * Cache discipline: `spec_by_method` (Map) and the internal streams-target
19
+ * set lazy-memoize because the Map is consulted per-RPC dispatch
20
+ * (`frontend_rpc_client.ts` wires it into `lookup_action_spec`) and the
21
+ * streams set is rebuilt by two public getters. Array-returning getters
22
+ * recompute on each call so callers can mutate the result freely
23
+ * (`.sort()`, `.push(injected)` on a copy, etc.) without affecting the
24
+ * registry — codegen is a build-time path where the extra `.filter` /
25
+ * `.map` work is negligible.
26
+ *
4
27
  * @module
5
28
  */
6
29
  import type { ActionSpecUnion, RequestResponseActionSpec, RemoteNotificationActionSpec, LocalCallActionSpec } from './action_spec.js';
@@ -9,26 +32,35 @@ import type { ActionSpecUnion, RequestResponseActionSpec, RemoteNotificationActi
9
32
  * Provides helper methods to get actions by various criteria.
10
33
  */
11
34
  export declare class ActionRegistry {
35
+ #private;
12
36
  readonly specs: Array<ActionSpecUnion>;
13
37
  constructor(specs: Array<ActionSpecUnion>);
14
38
  get spec_by_method(): Map<string, ActionSpecUnion>;
39
+ get methods(): Array<string>;
15
40
  get request_response_specs(): Array<RequestResponseActionSpec>;
16
41
  get remote_notification_specs(): Array<RemoteNotificationActionSpec>;
17
42
  get local_call_specs(): Array<LocalCallActionSpec>;
18
- get backend_specs(): Array<ActionSpecUnion>;
19
- get frontend_specs(): Array<ActionSpecUnion>;
20
- get backend_to_frontend_specs(): Array<ActionSpecUnion>;
21
- get frontend_to_backend_specs(): Array<ActionSpecUnion>;
22
- get public_specs(): Array<ActionSpecUnion>;
23
- get authenticated_specs(): Array<ActionSpecUnion>;
24
- get methods(): Array<string>;
25
43
  get request_response_methods(): Array<string>;
26
44
  get remote_notification_methods(): Array<string>;
27
45
  get local_call_methods(): Array<string>;
28
- get backend_methods(): Array<string>;
29
- get frontend_methods(): Array<string>;
46
+ get specs_relevant_to_frontend(): Array<ActionSpecUnion>;
47
+ get specs_relevant_to_backend(): Array<ActionSpecUnion>;
48
+ get methods_relevant_to_frontend(): Array<string>;
49
+ get methods_relevant_to_backend(): Array<string>;
50
+ get frontend_handled_specs(): Array<RequestResponseActionSpec>;
51
+ get backend_handled_specs(): Array<RequestResponseActionSpec>;
52
+ get frontend_handled_methods(): Array<string>;
53
+ get backend_handled_methods(): Array<string>;
54
+ get broadcast_specs(): Array<RemoteNotificationActionSpec>;
55
+ get broadcast_methods(): Array<string>;
56
+ get backend_initiated_specs(): Array<ActionSpecUnion>;
57
+ get backend_initiated_methods(): Array<string>;
58
+ get backend_to_frontend_specs(): Array<ActionSpecUnion>;
59
+ get frontend_to_backend_specs(): Array<ActionSpecUnion>;
30
60
  get frontend_to_backend_methods(): Array<string>;
31
61
  get backend_to_frontend_methods(): Array<string>;
62
+ get public_specs(): Array<ActionSpecUnion>;
63
+ get authenticated_specs(): Array<ActionSpecUnion>;
32
64
  get public_methods(): Array<string>;
33
65
  get authenticated_methods(): Array<string>;
34
66
  }
@@ -1 +1 @@
1
- {"version":3,"file":"action_registry.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_registry.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EACX,eAAe,EACf,yBAAyB,EACzB,4BAA4B,EAC5B,mBAAmB,EACnB,MAAM,kBAAkB,CAAC;AAQ1B;;;GAGG;AACH,qBAAa,cAAc;IAC1B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;gBAE3B,KAAK,EAAE,KAAK,CAAC,eAAe,CAAC;IAIzC,IAAI,cAAc,IAAI,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAEjD;IAED,IAAI,sBAAsB,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAE7D;IAED,IAAI,yBAAyB,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAEnE;IAED,IAAI,gBAAgB,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAEjD;IAKD,IAAI,aAAa,IAAI,KAAK,CAAC,eAAe,CAAC,CAE1C;IAED,IAAI,cAAc,IAAI,KAAK,CAAC,eAAe,CAAC,CAE3C;IAED,IAAI,yBAAyB,IAAI,KAAK,CAAC,eAAe,CAAC,CAEtD;IAED,IAAI,yBAAyB,IAAI,KAAK,CAAC,eAAe,CAAC,CAEtD;IAED,IAAI,YAAY,IAAI,KAAK,CAAC,eAAe,CAAC,CAEzC;IAED,IAAI,mBAAmB,IAAI,KAAK,CAAC,eAAe,CAAC,CAEhD;IAED,IAAI,OAAO,IAAI,KAAK,CAAC,MAAM,CAAC,CAE3B;IAED,IAAI,wBAAwB,IAAI,KAAK,CAAC,MAAM,CAAC,CAE5C;IAED,IAAI,2BAA2B,IAAI,KAAK,CAAC,MAAM,CAAC,CAE/C;IAED,IAAI,kBAAkB,IAAI,KAAK,CAAC,MAAM,CAAC,CAEtC;IAED,IAAI,eAAe,IAAI,KAAK,CAAC,MAAM,CAAC,CAEnC;IAED,IAAI,gBAAgB,IAAI,KAAK,CAAC,MAAM,CAAC,CAEpC;IAED,IAAI,2BAA2B,IAAI,KAAK,CAAC,MAAM,CAAC,CAE/C;IAED,IAAI,2BAA2B,IAAI,KAAK,CAAC,MAAM,CAAC,CAE/C;IAED,IAAI,cAAc,IAAI,KAAK,CAAC,MAAM,CAAC,CAElC;IAED,IAAI,qBAAqB,IAAI,KAAK,CAAC,MAAM,CAAC,CAEzC;CACD"}
1
+ {"version":3,"file":"action_registry.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EACX,eAAe,EACf,yBAAyB,EACzB,4BAA4B,EAC5B,mBAAmB,EACnB,MAAM,kBAAkB,CAAC;AAS1B;;;GAGG;AACH,qBAAa,cAAc;;IAC1B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;gBAE3B,KAAK,EAAE,KAAK,CAAC,eAAe,CAAC;IAKzC,IAAI,cAAc,IAAI,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAEjD;IAED,IAAI,OAAO,IAAI,KAAK,CAAC,MAAM,CAAC,CAE3B;IAID,IAAI,sBAAsB,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAE7D;IAED,IAAI,yBAAyB,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAEnE;IAED,IAAI,gBAAgB,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAEjD;IAED,IAAI,wBAAwB,IAAI,KAAK,CAAC,MAAM,CAAC,CAE5C;IAED,IAAI,2BAA2B,IAAI,KAAK,CAAC,MAAM,CAAC,CAE/C;IAED,IAAI,kBAAkB,IAAI,KAAK,CAAC,MAAM,CAAC,CAEtC;IAOD,IAAI,0BAA0B,IAAI,KAAK,CAAC,eAAe,CAAC,CAEvD;IAED,IAAI,yBAAyB,IAAI,KAAK,CAAC,eAAe,CAAC,CAEtD;IAED,IAAI,4BAA4B,IAAI,KAAK,CAAC,MAAM,CAAC,CAEhD;IAED,IAAI,2BAA2B,IAAI,KAAK,CAAC,MAAM,CAAC,CAE/C;IAOD,IAAI,sBAAsB,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAE7D;IAED,IAAI,qBAAqB,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAE5D;IAED,IAAI,wBAAwB,IAAI,KAAK,CAAC,MAAM,CAAC,CAE5C;IAED,IAAI,uBAAuB,IAAI,KAAK,CAAC,MAAM,CAAC,CAE3C;IASD,IAAI,eAAe,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAKzD;IAED,IAAI,iBAAiB,IAAI,KAAK,CAAC,MAAM,CAAC,CAErC;IAED,IAAI,uBAAuB,IAAI,KAAK,CAAC,eAAe,CAAC,CAKpD;IAED,IAAI,yBAAyB,IAAI,KAAK,CAAC,MAAM,CAAC,CAE7C;IAID,IAAI,yBAAyB,IAAI,KAAK,CAAC,eAAe,CAAC,CAEtD;IAED,IAAI,yBAAyB,IAAI,KAAK,CAAC,eAAe,CAAC,CAEtD;IAED,IAAI,2BAA2B,IAAI,KAAK,CAAC,MAAM,CAAC,CAE/C;IAED,IAAI,2BAA2B,IAAI,KAAK,CAAC,MAAM,CAAC,CAE/C;IAID,IAAI,YAAY,IAAI,KAAK,CAAC,eAAe,CAAC,CAEzC;IAED,IAAI,mBAAmB,IAAI,KAAK,CAAC,eAAe,CAAC,CAEhD;IAED,IAAI,cAAc,IAAI,KAAK,CAAC,MAAM,CAAC,CAElC;IAED,IAAI,qBAAqB,IAAI,KAAK,CAAC,MAAM,CAAC,CAEzC;CAaD"}