apcore-js 0.21.1 → 0.23.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/README.md +37 -3
- package/dist/acl-handlers.d.ts.map +1 -1
- package/dist/acl-handlers.js +5 -0
- package/dist/acl-handlers.js.map +1 -1
- package/dist/async-task.d.ts +49 -14
- package/dist/async-task.d.ts.map +1 -1
- package/dist/async-task.js +134 -39
- package/dist/async-task.js.map +1 -1
- package/dist/bindings.d.ts +13 -1
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +21 -5
- package/dist/bindings.js.map +1 -1
- package/dist/browser/index.d.ts +2 -2
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +2 -2
- package/dist/browser/index.js.map +1 -1
- package/dist/builtin-steps.d.ts +14 -12
- package/dist/builtin-steps.d.ts.map +1 -1
- package/dist/builtin-steps.js +99 -30
- package/dist/builtin-steps.js.map +1 -1
- package/dist/cancel.d.ts +23 -2
- package/dist/cancel.d.ts.map +1 -1
- package/dist/cancel.js +31 -6
- package/dist/cancel.js.map +1 -1
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -3
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +31 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +108 -14
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +36 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +111 -20
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +74 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +158 -11
- package/dist/errors.js.map +1 -1
- package/dist/events/emitter.d.ts +54 -16
- package/dist/events/emitter.d.ts.map +1 -1
- package/dist/events/emitter.js +162 -73
- package/dist/events/emitter.js.map +1 -1
- package/dist/events/index.d.ts +2 -1
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +1 -1
- package/dist/events/index.js.map +1 -1
- package/dist/events/retry.d.ts +35 -0
- package/dist/events/retry.d.ts.map +1 -0
- package/dist/events/retry.js +52 -0
- package/dist/events/retry.js.map +1 -0
- package/dist/events/subscribers.d.ts +54 -9
- package/dist/events/subscribers.d.ts.map +1 -1
- package/dist/events/subscribers.js +109 -67
- package/dist/events/subscribers.js.map +1 -1
- package/dist/executor.d.ts +4 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +157 -21
- package/dist/executor.js.map +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/middleware/circuit-breaker.d.ts +10 -4
- package/dist/middleware/circuit-breaker.d.ts.map +1 -1
- package/dist/middleware/circuit-breaker.js +49 -24
- package/dist/middleware/circuit-breaker.js.map +1 -1
- package/dist/middleware/index.d.ts +2 -2
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/manager.d.ts +6 -2
- package/dist/middleware/manager.d.ts.map +1 -1
- package/dist/middleware/manager.js +36 -1
- package/dist/middleware/manager.js.map +1 -1
- package/dist/middleware/platform-notify.d.ts +2 -3
- package/dist/middleware/platform-notify.d.ts.map +1 -1
- package/dist/middleware/platform-notify.js +5 -6
- package/dist/middleware/platform-notify.js.map +1 -1
- package/dist/middleware/retry.d.ts +33 -15
- package/dist/middleware/retry.d.ts.map +1 -1
- package/dist/middleware/retry.js +48 -23
- package/dist/middleware/retry.js.map +1 -1
- package/dist/observability/context-logger.d.ts +15 -11
- package/dist/observability/context-logger.d.ts.map +1 -1
- package/dist/observability/context-logger.js +40 -14
- package/dist/observability/context-logger.js.map +1 -1
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +11 -11
- package/dist/pipeline.js.map +1 -1
- package/dist/registry/multi-class.js +6 -17
- package/dist/registry/multi-class.js.map +1 -1
- package/dist/registry/registry.d.ts +46 -2
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +276 -36
- package/dist/registry/registry.js.map +1 -1
- package/dist/registry/version.d.ts.map +1 -1
- package/dist/registry/version.js +9 -2
- package/dist/registry/version.js.map +1 -1
- package/dist/schema/ref-resolver.d.ts.map +1 -1
- package/dist/schema/ref-resolver.js +2 -2
- package/dist/schema/ref-resolver.js.map +1 -1
- package/dist/schema/strict.d.ts.map +1 -1
- package/dist/schema/strict.js +4 -1
- package/dist/schema/strict.js.map +1 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/schema/validator.d.ts.map +1 -1
- package/dist/schema/validator.js +11 -20
- package/dist/schema/validator.js.map +1 -1
- package/dist/streaming.d.ts +21 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +32 -0
- package/dist/streaming.js.map +1 -0
- package/dist/sys-modules/control.d.ts +7 -0
- package/dist/sys-modules/control.d.ts.map +1 -1
- package/dist/sys-modules/control.js +32 -7
- package/dist/sys-modules/control.js.map +1 -1
- package/dist/sys-modules/manifest.js +7 -0
- package/dist/sys-modules/manifest.js.map +1 -1
- package/dist/sys-modules/registration.d.ts.map +1 -1
- package/dist/sys-modules/registration.js +18 -8
- package/dist/sys-modules/registration.js.map +1 -1
- package/dist/trace-context.d.ts.map +1 -1
- package/dist/trace-context.js +14 -3
- package/dist/trace-context.js.map +1 -1
- package/package.json +8 -4
- package/dist/registry/index.d.ts +0 -15
- package/dist/registry/index.d.ts.map +0 -1
- package/dist/registry/index.js +0 -11
- package/dist/registry/index.js.map +0 -1
- package/dist/schema/index.d.ts +0 -11
- package/dist/schema/index.d.ts.map +0 -1
- package/dist/schema/index.js +0 -9
- package/dist/schema/index.js.map +0 -1
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Central module registry for discovering, registering, and querying modules.
|
|
3
3
|
*/
|
|
4
4
|
import { getDefault } from '../config-defaults.js';
|
|
5
|
-
import { InvalidInputError, ModuleNotFoundError } from '../errors.js';
|
|
5
|
+
import { DuplicateModuleIdError, ErrorCodes, InvalidInputError, ModuleNotFoundError, StreamingInterfaceError, } from '../errors.js';
|
|
6
|
+
import { isStreamingModule } from '../streaming.js';
|
|
6
7
|
import { detectIdConflicts } from './conflicts.js';
|
|
7
8
|
import { resolveDependencies } from './dependencies.js';
|
|
8
9
|
import { resolveEntryPoint } from './entry-point.js';
|
|
@@ -137,22 +138,22 @@ export function isEphemeralModuleId(moduleId) {
|
|
|
137
138
|
function validateModuleId(moduleId, allowReserved) {
|
|
138
139
|
// 1. empty check (message byte-aligned with apcore-python and apcore-rust)
|
|
139
140
|
if (!moduleId || typeof moduleId !== 'string') {
|
|
140
|
-
throw new InvalidInputError('module_id must be a non-empty string');
|
|
141
|
+
throw new InvalidInputError('module_id must be a non-empty string', undefined, ErrorCodes.INVALID_MODULE_ID);
|
|
141
142
|
}
|
|
142
143
|
// 2. EBNF pattern check (message byte-aligned with apcore-python and apcore-rust:
|
|
143
144
|
// single quotes around the offending ID; bare regex source without /…/ delimiters)
|
|
144
145
|
if (!MODULE_ID_PATTERN.test(moduleId)) {
|
|
145
|
-
throw new InvalidInputError(`Invalid module ID: '${moduleId}'. Must match pattern: ${MODULE_ID_PATTERN.source} (lowercase, digits, underscores, dots only; no hyphens)
|
|
146
|
+
throw new InvalidInputError(`Invalid module ID: '${moduleId}'. Must match pattern: ${MODULE_ID_PATTERN.source} (lowercase, digits, underscores, dots only; no hyphens)`, undefined, ErrorCodes.INVALID_MODULE_ID);
|
|
146
147
|
}
|
|
147
148
|
// 3. length check
|
|
148
149
|
if (moduleId.length > MAX_MODULE_ID_LENGTH) {
|
|
149
|
-
throw new InvalidInputError(`Module ID exceeds maximum length of ${MAX_MODULE_ID_LENGTH}: ${moduleId.length}
|
|
150
|
+
throw new InvalidInputError(`Module ID exceeds maximum length of ${MAX_MODULE_ID_LENGTH}: ${moduleId.length}`, undefined, ErrorCodes.INVALID_MODULE_ID);
|
|
150
151
|
}
|
|
151
152
|
// 4. reserved word first-segment check (skipped for registerInternal)
|
|
152
153
|
if (!allowReserved) {
|
|
153
154
|
const firstSegment = moduleId.split('.')[0];
|
|
154
155
|
if (RESERVED_WORDS.has(firstSegment)) {
|
|
155
|
-
throw new InvalidInputError(`Module ID contains reserved word: '${firstSegment}'
|
|
156
|
+
throw new InvalidInputError(`Module ID contains reserved word: '${firstSegment}'`, undefined, ErrorCodes.INVALID_MODULE_ID);
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
}
|
|
@@ -178,6 +179,13 @@ export class Registry {
|
|
|
178
179
|
_refCounts = new Map();
|
|
179
180
|
_draining = new Set();
|
|
180
181
|
_drainResolvers = new Map();
|
|
182
|
+
/**
|
|
183
|
+
* In-flight async registrations: modules whose async onLoad has not yet
|
|
184
|
+
* resolved. Keys in this map are NOT visible via get()/has() until the
|
|
185
|
+
* promise resolves and the module is committed to _modules.
|
|
186
|
+
* This enforces deferred-publish for async onLoad (issue #65).
|
|
187
|
+
*/
|
|
188
|
+
_inFlight = new Map();
|
|
181
189
|
/**
|
|
182
190
|
* Optional EventEmitter for the ephemeral.* registry-side audit emits
|
|
183
191
|
* (RFC `apcore/docs/spec/rfc-ephemeral-modules.md` "Audit-event single-emit
|
|
@@ -349,7 +357,7 @@ export class Registry {
|
|
|
349
357
|
`'ephemeral.*' namespace: ${JSON.stringify(ids)}. The ephemeral.* ` +
|
|
350
358
|
`namespace is reserved for programmatically-registered modules and ` +
|
|
351
359
|
`may only be used via Registry.register(). Rename the offending ` +
|
|
352
|
-
`directory or extension namespace
|
|
360
|
+
`directory or extension namespace.`, undefined, ErrorCodes.INVALID_MODULE_ID);
|
|
353
361
|
}
|
|
354
362
|
async _applyIdMapOverrides(discovered) {
|
|
355
363
|
if (Object.keys(this._idMap).length === 0)
|
|
@@ -496,31 +504,45 @@ export class Registry {
|
|
|
496
504
|
]);
|
|
497
505
|
return resolveDependencies(modulesWithDeps, knownIds, moduleVersions);
|
|
498
506
|
}
|
|
499
|
-
_registerInOrder(loadOrder, validModules, rawMetadata) {
|
|
507
|
+
async _registerInOrder(loadOrder, validModules, rawMetadata) {
|
|
500
508
|
// Stage 8 (D-32). Conflict detection / ID validation already happened in
|
|
501
509
|
// `_filterIdConflicts` (stage 7), so this loop is purely a register pass.
|
|
510
|
+
//
|
|
511
|
+
// Deferred-publish contract (issue #65, audit D11-001 / D10-005): a module
|
|
512
|
+
// is NEVER published to `_modules`/`_moduleMeta`/`_lowercaseMap` until its
|
|
513
|
+
// `onLoad` (sync or async) has succeeded. This mirrors the public
|
|
514
|
+
// `_registerWithOnLoad` path: the caller observes either a fully-published
|
|
515
|
+
// module OR an `apcore.registry.module_load_failed` event — never a module
|
|
516
|
+
// that is visible mid-load. On `onLoad` failure we emit the event, skip
|
|
517
|
+
// publishing, and move to the next module (skip-on-failure, never abort).
|
|
502
518
|
let count = 0;
|
|
503
519
|
for (const modId of loadOrder) {
|
|
504
520
|
const mod = validModules.get(modId);
|
|
505
521
|
if (mod === undefined)
|
|
506
522
|
continue;
|
|
507
523
|
const modObj = mod;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
this._moduleMeta.set(modId, mergedMeta);
|
|
511
|
-
this._lowercaseMap.set(modId.toLowerCase(), modId);
|
|
524
|
+
// Run onLoad BEFORE publishing. Handle both sync and async onLoad: call
|
|
525
|
+
// it, and if it returns a Promise, await it.
|
|
512
526
|
if (typeof modObj['onLoad'] === 'function') {
|
|
513
527
|
try {
|
|
514
|
-
modObj['onLoad']();
|
|
528
|
+
const onLoadResult = modObj['onLoad']();
|
|
529
|
+
if (onLoadResult instanceof Promise) {
|
|
530
|
+
await onLoadResult;
|
|
531
|
+
}
|
|
515
532
|
}
|
|
516
533
|
catch (e) {
|
|
534
|
+
// Sync throw or async rejection: emit module_load_failed, do NOT
|
|
535
|
+
// publish, warn, and skip to the next module.
|
|
536
|
+
this._emitModuleLoadFailed(modId, e);
|
|
517
537
|
console.warn(`[apcore:registry] onLoad failed for ${modId}, skipping:`, e);
|
|
518
|
-
this._modules.delete(modId);
|
|
519
|
-
this._moduleMeta.delete(modId);
|
|
520
|
-
this._lowercaseMap.delete(modId.toLowerCase());
|
|
521
538
|
continue;
|
|
522
539
|
}
|
|
523
540
|
}
|
|
541
|
+
// Success (including empty / no-onLoad modules): publish all state.
|
|
542
|
+
const mergedMeta = mergeModuleMetadata(modObj, rawMetadata.get(modId) ?? {});
|
|
543
|
+
this._modules.set(modId, mod);
|
|
544
|
+
this._moduleMeta.set(modId, mergedMeta);
|
|
545
|
+
this._lowercaseMap.set(modId.toLowerCase(), modId);
|
|
524
546
|
this._triggerEvent(REGISTRY_EVENTS.REGISTER, modId, mod);
|
|
525
547
|
count++;
|
|
526
548
|
}
|
|
@@ -539,8 +561,18 @@ export class Registry {
|
|
|
539
561
|
* coexistence is not yet implemented in this SDK — when supplied, both
|
|
540
562
|
* fields are merged into the module's metadata so callers can read them
|
|
541
563
|
* back via `getDefinition()` and `list({tags})`. See PROTOCOL_SPEC §5.4.
|
|
564
|
+
*
|
|
565
|
+
* Returns a Promise that resolves once `onLoad` (if any) completes and the
|
|
566
|
+
* module is fully visible. For modules without `onLoad` or with a sync
|
|
567
|
+
* `onLoad`, the module is visible immediately (before the promise resolves)
|
|
568
|
+
* so existing sync callers that do not `await` continue to work.
|
|
569
|
+
*
|
|
570
|
+
* All sync validation errors (invalid ID, duplicate, streaming mismatch,
|
|
571
|
+
* sync onLoad failure) are thrown synchronously for backward compatibility
|
|
572
|
+
* with callers that use `expect(() => register(...)).toThrow(...)` patterns.
|
|
542
573
|
*/
|
|
543
574
|
register(moduleId, module, version, metadata, options) {
|
|
575
|
+
// 1. ID validation (always sync — throws synchronously for backward compat)
|
|
544
576
|
validateModuleId(moduleId, false);
|
|
545
577
|
// ephemeral.* registrations only land via this programmatic path —
|
|
546
578
|
// the filesystem discoverer rejects matching IDs upstream (see
|
|
@@ -551,54 +583,201 @@ export class Registry {
|
|
|
551
583
|
if (ephemeral) {
|
|
552
584
|
this._warnIfMissingApproval(moduleId, module);
|
|
553
585
|
}
|
|
586
|
+
// 2. Duplicate detection (sync — preserves backward compat with `.toThrow()` tests)
|
|
587
|
+
const conflict = detectIdConflicts(moduleId, new Set([...this._modules.keys(), ...this._inFlight.keys()]), RESERVED_WORDS, this._lowercaseMap);
|
|
588
|
+
if (conflict !== null) {
|
|
589
|
+
if (conflict.severity === 'error') {
|
|
590
|
+
if (conflict.type === 'duplicate_id') {
|
|
591
|
+
throw new DuplicateModuleIdError(moduleId);
|
|
592
|
+
}
|
|
593
|
+
throw new InvalidInputError(conflict.message);
|
|
594
|
+
}
|
|
595
|
+
console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
|
|
596
|
+
}
|
|
597
|
+
// 3. Streaming annotation validation (sync — preserves .toThrow() compat)
|
|
598
|
+
const modForStreaming = module;
|
|
599
|
+
const annForStreaming = modForStreaming['annotations'];
|
|
600
|
+
if (annForStreaming != null && typeof annForStreaming === 'object') {
|
|
601
|
+
const streamingFlag = annForStreaming['streaming'];
|
|
602
|
+
if (streamingFlag === true) {
|
|
603
|
+
const hasStreamMethod = typeof modForStreaming['stream'] === 'function';
|
|
604
|
+
const hasMarker = modForStreaming[Symbol.for('apcore.streaming')] === true;
|
|
605
|
+
if (!isStreamingModule(module)) {
|
|
606
|
+
throw new StreamingInterfaceError(moduleId, true, hasStreamMethod, hasMarker);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// 4. Custom validator (may be async)
|
|
554
611
|
if (this._customValidator !== null) {
|
|
555
612
|
const result = this._customValidator.validate(module);
|
|
556
613
|
if (result instanceof Promise) {
|
|
557
|
-
|
|
614
|
+
return result.then((errors) => {
|
|
615
|
+
if (errors.length > 0) {
|
|
616
|
+
throw new InvalidInputError(`Custom validator rejected module '${moduleId}': ${errors.join('; ')}`);
|
|
617
|
+
}
|
|
618
|
+
return this._registerWithOnLoad(moduleId, module, version, metadata, options, ephemeral);
|
|
619
|
+
});
|
|
558
620
|
}
|
|
559
621
|
if (result.length > 0) {
|
|
560
622
|
throw new InvalidInputError(`Custom validator rejected module '${moduleId}': ${result.join('; ')}`);
|
|
561
623
|
}
|
|
562
624
|
}
|
|
625
|
+
return this._registerWithOnLoad(moduleId, module, version, metadata, options, ephemeral);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Core registration logic after all sync validation has passed.
|
|
629
|
+
* Handles both sync and async onLoad with deferred-publish for async.
|
|
630
|
+
*
|
|
631
|
+
* For sync or absent onLoad: module becomes visible immediately, returns
|
|
632
|
+
* a resolved Promise (backward-compatible sync usage continues to work).
|
|
633
|
+
* For async onLoad: module is tracked in _inFlight and hidden from get()/has()
|
|
634
|
+
* until onLoad resolves; then committed to _modules and made visible.
|
|
635
|
+
*/
|
|
636
|
+
_registerWithOnLoad(moduleId, module, version, metadata, options, ephemeral) {
|
|
563
637
|
const overrides = { ...(metadata ?? {}) };
|
|
564
638
|
if (version !== undefined && version !== null) {
|
|
565
639
|
overrides['version'] = version;
|
|
566
640
|
}
|
|
567
|
-
|
|
641
|
+
// Detect async onLoad
|
|
642
|
+
const modObj = module;
|
|
643
|
+
if (typeof modObj['onLoad'] === 'function') {
|
|
644
|
+
// Call onLoad and check if it returns a Promise.
|
|
645
|
+
// Sync onLoad must emit module_load_failed on throw (Issue #65 gap;
|
|
646
|
+
// mirror the async-Promise rejection branch below). The strong-guarantee
|
|
647
|
+
// invariant — caller observes either fully-published or
|
|
648
|
+
// module_load_failed — applies to every registration path including
|
|
649
|
+
// this entry point.
|
|
650
|
+
// A-D-013: reserve the in-flight slot BEFORE invoking onLoad, for both
|
|
651
|
+
// sync and async onLoad. The module MUST NOT be observable via get()/has()
|
|
652
|
+
// while onLoad runs (strong-guarantee invariant #65). Reserving uniformly
|
|
653
|
+
// — not only for the async branch — makes the in-flight gate effective
|
|
654
|
+
// for a sync onLoad that re-entrantly inspects the registry, matching
|
|
655
|
+
// apcore-python (_in_flight.add before on_load) and apcore-rust.
|
|
656
|
+
// A resolved placeholder Promise is used as the sync sentinel; the
|
|
657
|
+
// async branch overwrites it with the real loadPromise below.
|
|
658
|
+
this._inFlight.set(moduleId, Promise.resolve());
|
|
659
|
+
let onLoadResult;
|
|
660
|
+
try {
|
|
661
|
+
onLoadResult = modObj['onLoad']();
|
|
662
|
+
}
|
|
663
|
+
catch (e) {
|
|
664
|
+
// Surface sync onLoad failures as a rejected Promise so the
|
|
665
|
+
// strong-guarantee invariant — `register()` always returns a Promise
|
|
666
|
+
// — holds for sync and async onLoad alike. Without this, callers
|
|
667
|
+
// doing `await registry.register(...)` would see sync throws and the
|
|
668
|
+
// module_load_failed event would arrive before the await target.
|
|
669
|
+
this._inFlight.delete(moduleId);
|
|
670
|
+
this._emitModuleLoadFailed(moduleId, e);
|
|
671
|
+
return Promise.reject(e);
|
|
672
|
+
}
|
|
673
|
+
if (onLoadResult instanceof Promise) {
|
|
674
|
+
// Async onLoad: deferred-publish
|
|
675
|
+
// Register metadata and lowercase index (but NOT _modules) so conflict
|
|
676
|
+
// detection works for concurrent registrations of the same ID.
|
|
677
|
+
const modObjFull = module;
|
|
678
|
+
this._moduleMeta.set(moduleId, mergeModuleMetadata(modObjFull, overrides));
|
|
679
|
+
this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
|
|
680
|
+
const loadPromise = onLoadResult.then(() => {
|
|
681
|
+
// Commit module to visible state
|
|
682
|
+
this._modules.set(moduleId, module);
|
|
683
|
+
this._inFlight.delete(moduleId);
|
|
684
|
+
this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
|
|
685
|
+
if (ephemeral) {
|
|
686
|
+
this._emitEphemeralAudit('apcore.registry.module_registered', moduleId, options?.context ?? null);
|
|
687
|
+
}
|
|
688
|
+
}, (err) => {
|
|
689
|
+
// onLoad failed: rollback all state
|
|
690
|
+
this._inFlight.delete(moduleId);
|
|
691
|
+
this._moduleMeta.delete(moduleId);
|
|
692
|
+
this._lowercaseMap.delete(moduleId.toLowerCase());
|
|
693
|
+
// Emit module_load_failed event (spec #65)
|
|
694
|
+
this._emitModuleLoadFailed(moduleId, err);
|
|
695
|
+
throw err;
|
|
696
|
+
});
|
|
697
|
+
// Guard against unhandled rejection if caller fire-and-forgets (no await)
|
|
698
|
+
loadPromise.catch(() => { });
|
|
699
|
+
this._inFlight.set(moduleId, loadPromise);
|
|
700
|
+
return loadPromise;
|
|
701
|
+
}
|
|
702
|
+
// Sync onLoad returned (possibly undefined) — fall through to normal
|
|
703
|
+
// registration. Release the in-flight reservation now that onLoad has
|
|
704
|
+
// succeeded; the module becomes visible on the publish below (A-D-013).
|
|
705
|
+
this._inFlight.delete(moduleId);
|
|
706
|
+
}
|
|
707
|
+
// Sync path: no onLoad, or sync onLoad already called above
|
|
708
|
+
this._modules.set(moduleId, module);
|
|
709
|
+
this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
|
|
710
|
+
const modObjFull = module;
|
|
711
|
+
this._moduleMeta.set(moduleId, mergeModuleMetadata(modObjFull, overrides));
|
|
712
|
+
this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
|
|
568
713
|
if (ephemeral) {
|
|
569
714
|
this._emitEphemeralAudit('apcore.registry.module_registered', moduleId, options?.context ?? null);
|
|
570
715
|
}
|
|
716
|
+
return Promise.resolve();
|
|
571
717
|
}
|
|
572
|
-
/**
|
|
718
|
+
/**
|
|
719
|
+
* Inner registration — no validator, no ID validation. Used by discover() paths that run
|
|
720
|
+
* their own checks. Validates streaming annotation, runs sync onLoad, commits to _modules.
|
|
721
|
+
*/
|
|
573
722
|
_registerImpl(moduleId, module, metadataOverrides = {}) {
|
|
574
723
|
// Algorithm A03: detect ID conflicts (exact duplicate, reserved word, case collision)
|
|
575
|
-
const conflict = detectIdConflicts(moduleId, new Set(this._modules.keys()), RESERVED_WORDS, this._lowercaseMap);
|
|
724
|
+
const conflict = detectIdConflicts(moduleId, new Set([...this._modules.keys(), ...this._inFlight.keys()]), RESERVED_WORDS, this._lowercaseMap);
|
|
576
725
|
if (conflict !== null) {
|
|
577
726
|
if (conflict.severity === 'error') {
|
|
578
727
|
throw new InvalidInputError(conflict.message);
|
|
579
728
|
}
|
|
580
|
-
|
|
581
|
-
|
|
729
|
+
console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
|
|
730
|
+
}
|
|
731
|
+
// Streaming annotation validation: if module declares streaming=true it must
|
|
732
|
+
// implement the StreamingModule interface (has stream() + STREAMING_MARKER).
|
|
733
|
+
const modForStreaming = module;
|
|
734
|
+
const annForStreaming = modForStreaming['annotations'];
|
|
735
|
+
if (annForStreaming != null && typeof annForStreaming === 'object') {
|
|
736
|
+
const streamingFlag = annForStreaming['streaming'];
|
|
737
|
+
if (streamingFlag === true) {
|
|
738
|
+
const hasStreamMethod = typeof modForStreaming['stream'] === 'function';
|
|
739
|
+
const hasMarker = modForStreaming[Symbol.for('apcore.streaming')] === true;
|
|
740
|
+
if (!isStreamingModule(module)) {
|
|
741
|
+
throw new StreamingInterfaceError(moduleId, true, hasStreamMethod, hasMarker);
|
|
742
|
+
}
|
|
582
743
|
}
|
|
583
744
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
//
|
|
587
|
-
//
|
|
745
|
+
// A-D-REG-003 / spec REG-003: deferred-publish for the discover path.
|
|
746
|
+
// Reserve the slot in metadata + lowercase index (for conflict detection)
|
|
747
|
+
// but do NOT insert into `_modules` until onLoad has completed. The
|
|
748
|
+
// strong-guarantee invariant (#65) applies uniformly to every registration
|
|
749
|
+
// path including discover/hot-reload — observers must never see a module
|
|
750
|
+
// that subsequently fails its onLoad.
|
|
588
751
|
const modObj = module;
|
|
589
752
|
this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, metadataOverrides));
|
|
590
|
-
|
|
753
|
+
this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
|
|
754
|
+
// Call onLoad if available (sync only in this discover() path)
|
|
591
755
|
if (typeof modObj['onLoad'] === 'function') {
|
|
756
|
+
let onLoadResult;
|
|
592
757
|
try {
|
|
593
|
-
modObj['onLoad']();
|
|
758
|
+
onLoadResult = modObj['onLoad']();
|
|
594
759
|
}
|
|
595
760
|
catch (e) {
|
|
596
|
-
|
|
761
|
+
// Rollback the reservation: nothing was published, so observers
|
|
762
|
+
// never saw the module.
|
|
597
763
|
this._moduleMeta.delete(moduleId);
|
|
598
764
|
this._lowercaseMap.delete(moduleId.toLowerCase());
|
|
765
|
+
this._emitModuleLoadFailed(moduleId, e);
|
|
599
766
|
throw e;
|
|
600
767
|
}
|
|
768
|
+
if (onLoadResult instanceof Promise) {
|
|
769
|
+
// Async onLoad in discover() path: warn and skip — deferred-publish
|
|
770
|
+
// with async onLoad is only available via the public register() API.
|
|
771
|
+
console.warn(`[apcore:registry] Module '${moduleId}' has async onLoad in discover() path ` +
|
|
772
|
+
`— async onLoad is not supported here; use Registry.register() instead. Module skipped.`);
|
|
773
|
+
this._moduleMeta.delete(moduleId);
|
|
774
|
+
this._lowercaseMap.delete(moduleId.toLowerCase());
|
|
775
|
+
onLoadResult.catch(() => { }); // prevent unhandled rejection
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
601
778
|
}
|
|
779
|
+
// onLoad succeeded — now commit the module to the visible map.
|
|
780
|
+
this._modules.set(moduleId, module);
|
|
602
781
|
this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
|
|
603
782
|
}
|
|
604
783
|
unregister(moduleId, options) {
|
|
@@ -641,9 +820,20 @@ export class Registry {
|
|
|
641
820
|
if (moduleId === '') {
|
|
642
821
|
throw new ModuleNotFoundError('');
|
|
643
822
|
}
|
|
823
|
+
// In-flight modules are not yet visible — spec #65: module MUST NOT be
|
|
824
|
+
// observable via get() until onLoad completes. Cross-SDK canonical
|
|
825
|
+
// behaviour (Python/Rust, A-D-002) is to return null for an in-flight id,
|
|
826
|
+
// matching this SDK's own well-formed-unregistered → null contract and
|
|
827
|
+
// getDefinition()'s null return for the same case (asymmetry removed).
|
|
828
|
+
if (this._inFlight.has(moduleId)) {
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
644
831
|
return this._modules.get(moduleId) ?? null;
|
|
645
832
|
}
|
|
646
833
|
has(moduleId) {
|
|
834
|
+
// In-flight modules are not visible (same invariant as get())
|
|
835
|
+
if (this._inFlight.has(moduleId))
|
|
836
|
+
return false;
|
|
647
837
|
return this._modules.has(moduleId);
|
|
648
838
|
}
|
|
649
839
|
/**
|
|
@@ -656,11 +846,25 @@ export class Registry {
|
|
|
656
846
|
* `includeHidden: true` to enumerate every registered module — useful
|
|
657
847
|
* for introspection tools, debug consoles, and tests.
|
|
658
848
|
*/
|
|
849
|
+
/**
|
|
850
|
+
* Return sorted list of unique registered module IDs, optionally filtered.
|
|
851
|
+
*
|
|
852
|
+
* @param options.tags When supplied, only modules carrying *all* of the given tags are returned.
|
|
853
|
+
* @param options.prefix When supplied, only IDs starting with the prefix are returned.
|
|
854
|
+
* @param options.visibility Filter by module visibility. Supported: `['public', 'hidden']`.
|
|
855
|
+
* Defaults to `['public']`. Aligned with apcore D-24.
|
|
856
|
+
* @param options.includeHidden Deprecated. Use `visibility: ['public', 'hidden']` instead.
|
|
857
|
+
*/
|
|
659
858
|
list(options) {
|
|
660
859
|
let ids = [...this._modules.keys()];
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
860
|
+
// D-24 alignment: visibility list takes precedence over legacy includeHidden bool.
|
|
861
|
+
const vis = options?.visibility ?? (options?.includeHidden === true ? ['public', 'hidden'] : ['public']);
|
|
862
|
+
const showPublic = vis.includes('public');
|
|
863
|
+
const showHidden = vis.includes('hidden');
|
|
864
|
+
ids = ids.filter((id) => {
|
|
865
|
+
const isDisc = this._isDiscoverable(id);
|
|
866
|
+
return (isDisc && showPublic) || (!isDisc && showHidden);
|
|
867
|
+
});
|
|
664
868
|
if (options?.prefix != null) {
|
|
665
869
|
ids = ids.filter((id) => id.startsWith(options.prefix));
|
|
666
870
|
}
|
|
@@ -1033,11 +1237,13 @@ export class Registry {
|
|
|
1033
1237
|
if (conflict.severity === 'error') {
|
|
1034
1238
|
throw new InvalidInputError(conflict.message);
|
|
1035
1239
|
}
|
|
1036
|
-
|
|
1037
|
-
console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
|
|
1038
|
-
}
|
|
1240
|
+
console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
|
|
1039
1241
|
}
|
|
1040
|
-
|
|
1242
|
+
// A-D-REG-004 / spec REG-003: deferred-publish for registerInternal.
|
|
1243
|
+
// Reserve metadata + lowercase index but defer the visible _modules
|
|
1244
|
+
// insert until onLoad succeeds so the strong-guarantee invariant (#65)
|
|
1245
|
+
// holds uniformly across `register()`, `registerInternal`, and
|
|
1246
|
+
// discover/hot-reload paths.
|
|
1041
1247
|
const modObj = module;
|
|
1042
1248
|
this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
|
|
1043
1249
|
// Mirror apcore-python register_internal and apcore-rust register_core:
|
|
@@ -1051,12 +1257,13 @@ export class Registry {
|
|
|
1051
1257
|
modObj['onLoad']();
|
|
1052
1258
|
}
|
|
1053
1259
|
catch (e) {
|
|
1054
|
-
this._modules.delete(moduleId);
|
|
1055
1260
|
this._moduleMeta.delete(moduleId);
|
|
1056
1261
|
this._lowercaseMap.delete(moduleId.toLowerCase());
|
|
1262
|
+
this._emitModuleLoadFailed(moduleId, e);
|
|
1057
1263
|
throw e;
|
|
1058
1264
|
}
|
|
1059
1265
|
}
|
|
1266
|
+
this._modules.set(moduleId, module);
|
|
1060
1267
|
this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
|
|
1061
1268
|
}
|
|
1062
1269
|
/**
|
|
@@ -1272,6 +1479,39 @@ export class Registry {
|
|
|
1272
1479
|
}
|
|
1273
1480
|
return { caller_id: callerId, identity: snapshot, namespace_class: 'ephemeral' };
|
|
1274
1481
|
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Emit `apcore.registry.module_load_failed` when onLoad raises (spec #65).
|
|
1484
|
+
* Delivered synchronously if the emitter is wired; logged as warn otherwise.
|
|
1485
|
+
*/
|
|
1486
|
+
_emitModuleLoadFailed(moduleId, err) {
|
|
1487
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1488
|
+
const payload = {
|
|
1489
|
+
module_id: moduleId,
|
|
1490
|
+
callback_name: 'module.onLoad',
|
|
1491
|
+
error_type: error.constructor?.name ?? 'Error',
|
|
1492
|
+
error_message: error.message,
|
|
1493
|
+
timestamp: new Date().toISOString(),
|
|
1494
|
+
};
|
|
1495
|
+
const emitter = this._eventEmitter;
|
|
1496
|
+
if (emitter === null) {
|
|
1497
|
+
console.warn(`[apcore:registry] module_load_failed module_id=${moduleId} ` +
|
|
1498
|
+
`error_type=${payload['error_type']} message=${error.message}`);
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const event = {
|
|
1502
|
+
eventType: 'apcore.registry.module_load_failed',
|
|
1503
|
+
moduleId,
|
|
1504
|
+
timestamp: new Date().toISOString(),
|
|
1505
|
+
severity: 'error',
|
|
1506
|
+
data: payload,
|
|
1507
|
+
};
|
|
1508
|
+
try {
|
|
1509
|
+
emitter.emit(event);
|
|
1510
|
+
}
|
|
1511
|
+
catch (emitErr) {
|
|
1512
|
+
console.error('[apcore:registry] Failed to emit module_load_failed:', emitErr);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1275
1515
|
/**
|
|
1276
1516
|
* Emit the canonical ephemeral.* audit event to the wired EventEmitter
|
|
1277
1517
|
* (or warn-log if none is wired). The bridge in
|