diff --git a/docs/generated/API.md b/docs/generated/API.md
index 675a30ae..3da516f5 100644
--- a/docs/generated/API.md
+++ b/docs/generated/API.md
@@ -581,17 +581,18 @@ await api.slothlet.api.reload();
-#### api.slothlet.api.remove(pathOrModuleId) ⇒ Promise.<void>
+#### api.slothlet.api.remove(pathOrModuleId, apiPath?) ⇒ Promise.<boolean>
-Unmount an API module at runtime.
+Unmount an API module at runtime. remove(id) removes every path the module owns; remove(apiPath) removes that path's whole subtree; the two-argument remove(moduleID, apiPath) removes only that module's single node at the path, leaving sibling modules sharing the mount and the module's other mounts intact. Resolves to whether anything was removed.
**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI)
| Param | Type | Description |
| --- | --- | --- |
| pathOrModuleId | string | |
+| apiPath? | string | |
-**Returns**: Promise.<void>
+**Returns**: Promise.<boolean>
**Example**
```javascript
diff --git a/src/lib/builders/api_builder.mjs b/src/lib/builders/api_builder.mjs
index 9ca527de..8bf71b65 100644
--- a/src/lib/builders/api_builder.mjs
+++ b/src/lib/builders/api_builder.mjs
@@ -803,16 +803,26 @@ export class ApiBuilder extends ComponentBase {
/**
* @param {string} pathOrModuleId - API path or module ID to remove.
- * @returns {Promise}
+ * @param {string} [apiPath] - Optional mount path to scope removal to. When given, the first
+ * argument is treated as a moduleID and ONLY that module's node at `apiPath` is removed —
+ * other modules sharing the path, and that module's other mounts, are left untouched. Omit
+ * it for the whole-module (by id) or whole-subtree (by path) removal.
+ * @returns {Promise} True if something was removed, false if nothing matched.
* @public
*
* @description
- * Removes API modules by apiPath or moduleID from the current instance.
+ * Removes API modules by apiPath or moduleID from the current instance. `remove(id)` removes
+ * every path the module owns; `remove(apiPath)` removes that path's whole subtree; and the
+ * two-argument `remove(moduleID, apiPath)` removes only that module's single node at the path.
*
* @example
- * await api.slothlet.api.remove("plugins.tools");
+ * await api.slothlet.api.remove("plugins.tools"); // by api path
+ * @example
+ * await api.slothlet.api.remove("plugins-core"); // by module id (all its paths)
+ * @example
+ * await api.slothlet.api.remove("plugins-core", "plugins.tools"); // just that module's node
*/
- remove: async function slothlet_api_remove(pathOrModuleId) {
+ remove: async function slothlet_api_remove(pathOrModuleId, apiPath) {
// Check if remove mutation is allowed
if (!config.api?.mutations?.remove) {
throw new slothlet.SlothletError("INVALID_CONFIG_MUTATIONS_DISABLED", {
@@ -828,7 +838,16 @@ export class ApiBuilder extends ComponentBase {
validationError: true
});
}
- return slothlet.handlers.apiManager.removeApiComponent(pathOrModuleId);
+ // The optional scoping path, when supplied, must be a string.
+ if (apiPath !== undefined && typeof apiPath !== "string") {
+ throw new slothlet.SlothletError("INVALID_ARGUMENT", {
+ argument: "apiPath",
+ expected: "string",
+ received: typeof apiPath,
+ validationError: true
+ });
+ }
+ return slothlet.handlers.apiManager.removeApiComponent(pathOrModuleId, { scopedApiPath: apiPath });
},
/**
diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs
index 0b365986..cbe65a34 100644
--- a/src/lib/handlers/api-manager.mjs
+++ b/src/lib/handlers/api-manager.mjs
@@ -47,6 +47,7 @@ import { translate } from "@cldmv/slothlet/i18n";
import { ComponentBase } from "#factories/component-base";
import { UnifiedWrapper, resolveWrapper } from "#handlers/unified-wrapper";
import { isFrameworkInternal } from "#handlers/framework-internals";
+import { MODULE_ID_SEPARATOR } from "#handlers/metadata";
// Node-only static imports resolved via top-level await so `node:*` never
// enters the static-import graph in browser bundles. ApiManager methods that
@@ -1284,19 +1285,19 @@ export class ApiManager extends ComponentBase {
* await this.restoreApiPath("plugins", "plugins-core");
*/
async restoreApiPath(apiPath, moduleID) {
- // moduleID is always supplied by callers; the null fallback is unreachable.
- /* v8 ignore next */
+ // Unreachable in the test suite, and by construction: restoreApiPath is a defensive rollback
+ // fallback whose four call sites are all in unreachable branches — the "getCurrentValue returned
+ // undefined" else in each removeApiComponent restore path (a restored node always resolves to a
+ // concrete value, so that else never runs) and the reload "zero affected caches" fallback (a
+ // reload always has at least one cache). No reachable path executes this method, so the whole
+ // body is ignored as genuinely-dead defensive code rather than with a per-line "never in tests".
+ /* v8 ignore start */
const normalizedModuleId = moduleID || null;
const historyEntry = this.state.addHistory
.slice()
.reverse()
- // addHistory is always empty when restoreApiPath is called in tests; the ternary fallback never fires.
- /* v8 ignore start */
.find((entry) => entry.apiPath === apiPath && (normalizedModuleId ? entry.moduleID === normalizedModuleId : true));
- /* v8 ignore stop */
- // historyEntry is never populated in tests (addHistory is empty on restore calls).
- /* v8 ignore start */
if (historyEntry) {
await this.addApiComponent({
apiPath: historyEntry.apiPath,
@@ -1312,10 +1313,7 @@ export class ApiManager extends ComponentBase {
});
return;
}
- /* v8 ignore stop */
- // restoreApiPath is only ever called with "base" or "core"; the IF FALSE arm is unreachable.
- /* v8 ignore next */
if (normalizedModuleId === "base" || normalizedModuleId === "core") {
const baseApi = await this.slothlet.builders.builder.buildAPI({
dir: this.____config.dir,
@@ -1336,8 +1334,6 @@ export class ApiManager extends ComponentBase {
// For eager mode: __impl is the actual implementation (object/function) - extract it
// For lazy mode: if __impl is a function, it's unmaterialized - extract it anyway for reload
const baseValueRaw = resolveWrapper(baseValue);
- // baseValue is always a wrapper proxy from buildAPI; both conditions always true.
- /* v8 ignore next */
if (baseValue && baseValueRaw !== null) {
baseValue = baseValueRaw.__impl;
}
@@ -1355,6 +1351,7 @@ export class ApiManager extends ComponentBase {
moduleID: normalizedModuleId
});
}
+ /* v8 ignore stop */
}
/**
@@ -1496,8 +1493,36 @@ export class ApiManager extends ComponentBase {
});
}
+ // A user-supplied moduleID must not contain the reserved composite separator: it is the delimiter
+ // slothlet joins `moduleID` and `apiPath` with in the internal metadata key, so a moduleID carrying
+ // it would corrupt that key and make the mount unresolvable. Refuse it up front with a named error.
+ if (typeof restOptions.moduleID === "string" && restOptions.moduleID.includes(MODULE_ID_SEPARATOR)) {
+ throw new this.SlothletError("MODULE_ID_RESERVED_SEPARATOR", {
+ moduleID: restOptions.moduleID,
+ separator: MODULE_ID_SEPARATOR,
+ validationError: true
+ });
+ }
+
const { apiPath: normalizedPath, parts } = this.normalizeApiPath(apiPath);
+ // The reserved composite separator is disallowed in the apiPath as well, not only the moduleID:
+ // remove()/leaves() recover a base moduleID by splitting their argument on the separator, so a mount
+ // whose apiPath carried it would let a later remove(`segment`) split the path to "seg" and detach
+ // the wrong module. When an explicit (clean) moduleID is supplied the auto-generated-id guard below
+ // can't catch it, so reject the apiPath here as a path-validation error. The no-moduleID case falls
+ // through to that guard instead, where the offending token surfaces as the auto-generated moduleID.
+ if (typeof restOptions.moduleID === "string" && normalizedPath.includes(MODULE_ID_SEPARATOR)) {
+ const segIndex = parts.findIndex((p) => p.includes(MODULE_ID_SEPARATOR));
+ throw new this.SlothletError("INVALID_CONFIG_API_PATH_INVALID", {
+ apiPath: normalizedPath,
+ segment: parts[segIndex],
+ index: segIndex,
+ reason: translate("API_PATH_REASON_RESERVED_SEPARATOR"),
+ validationError: true
+ });
+ }
+
// Compute effective (versioned) mount path when versionConfig.version is present
let effectivePath = normalizedPath;
let effectiveParts = parts;
@@ -1694,6 +1719,18 @@ export class ApiManager extends ComponentBase {
}
const moduleID = restOptions.moduleID ? String(restOptions.moduleID) : this.buildDefaultModuleId(normalizedPath, resolvedFolderPath);
+ // No moduleID was supplied, so the id is derived from the apiPath; if that apiPath carries the reserved
+ // separator the auto-generated id inherits it, breaking the "no moduleID contains the separator"
+ // invariant that composite splitting (remove/metadata) relies on. A user-supplied id is refused above,
+ // and a supplied id with a separator-bearing apiPath is refused as an INVALID_CONFIG_API_PATH_INVALID
+ // after normalizeApiPath; this catches the remaining auto-generated case at the point the id is finalized.
+ if (moduleID.includes(MODULE_ID_SEPARATOR)) {
+ throw new this.SlothletError("MODULE_ID_RESERVED_SEPARATOR", {
+ moduleID,
+ separator: MODULE_ID_SEPARATOR,
+ validationError: true
+ });
+ }
// buildDefaultModuleId always returns a non-empty "_" string (randomSuffix is
// always 6 chars), and String(truthy-moduleID) always produces a non-empty string.
// So !moduleID is never true — this guard is a defensive belt-and-suspenders check.
@@ -2140,9 +2177,14 @@ export class ApiManager extends ComponentBase {
/* v8 ignore next */
if (this.slothlet.handlers.ownership) {
if (restOptions.recordHistory !== false) {
+ // For a synthetic / in-memory add there is no file to re-read: record the ORIGINAL inline
+ // value (the function / export map / `{exports,...}` object the caller passed) as folderPath
+ // so replay and restore re-run the identical synthetic add. `resolvedFolderPath` is only the
+ // `synthetic:` sentinel, which replay would wrongly treat as a filesystem path (#117).
+ const historyFolderPath = isSynthetic ? folderPath : resolvedFolderPath;
this.state.addHistory.push({
apiPath: normalizedPath,
- folderPath: resolvedFolderPath,
+ folderPath: historyFolderPath,
options: { ...restOptions, metadata, moduleID },
moduleID,
versionConfig: versionConfig || null
@@ -2153,7 +2195,7 @@ export class ApiManager extends ComponentBase {
this.state.operationHistory.push({
type: "add",
apiPath: normalizedPath,
- folderPath: resolvedFolderPath,
+ folderPath: historyFolderPath,
options: { ...restOptions, metadata, moduleID },
moduleID,
versionConfig: versionConfig || null
@@ -2333,20 +2375,31 @@ export class ApiManager extends ComponentBase {
/**
* Remove API modules at runtime.
- * @param {string} pathOrModuleId - API path (with dots) or module ID (with underscore) to remove.
- * @returns {Promise}
+ * @param {string} pathOrModuleId - An apiPath (dotted), a moduleID, or the composite `__metadata.moduleID`.
+ * @param {object} [options={}] - Options.
+ * @param {string} [options.scopedApiPath] - When set, `pathOrModuleId` is resolved strictly as a
+ * moduleID and only that module's single node at `scopedApiPath` is removed (drives the public
+ * two-argument `api.remove(moduleID, apiPath)`); sibling modules and the module's other mounts stay.
+ * @param {boolean} [options.recordHistory=true] - Whether to record the removal in the add/operation history.
+ * @returns {Promise} True if something was removed, false if nothing matched.
* @throws {SlothletError} When inputs are invalid.
* @package
*
* @description
- * Removes an API subtree by apiPath or removes all paths owned by a moduleID.
- * Automatically detects whether the parameter is a moduleID (contains underscore) or apiPath.
+ * Removes an API subtree by apiPath, or every path owned by a moduleID. The argument is resolved by
+ * splitting on the reserved composite separator ({@link MODULE_ID_SEPARATOR}): a plain id (which can
+ * never contain the separator) passes through whole, while a composite `__metadata.moduleID` strips to
+ * its base. It matches a registered module verbatim or by its auto-generated `_` form, and
+ * otherwise falls back to treating the argument as an apiPath.
*
* @example
* await manager.removeApiComponent("plugins.tools"); // Remove by API path
*
* @example
- * await manager.removeApiComponent("plugins_abc123"); // Remove by module ID
+ * await manager.removeApiComponent("plugins-core"); // Remove all paths owned by a module ID
+ *
+ * @example
+ * await manager.removeApiComponent("plugins-core", { scopedApiPath: "plugins.tools" }); // just that node
*/
async removeApiComponent(pathOrModuleId, options = {}) {
const recordHistory = options.recordHistory !== false;
@@ -2359,21 +2412,31 @@ export class ApiManager extends ComponentBase {
});
}
+ // Two-argument form remove(moduleID, apiPath): scope removal to a single node the module owns,
+ // rather than the whole module (by id) or whole subtree (by path). When set, pathOrModuleId is
+ // resolved strictly as a moduleID and only its node at `scopedApiPath` is removed.
+ const scopedApiPath = typeof options.scopedApiPath === "string" ? options.scopedApiPath : null;
+
// Detect if this is a moduleID or apiPath
// Try moduleID first (more specific), then fall back to API path
let apiPath = null;
let moduleID;
if (this.slothlet.handlers.ownership) {
- // Extract moduleID from full moduleID format "moduleID:path" if present
- const candidateModuleID = pathOrModuleId.split(":")[0];
-
- // Try to find a matching moduleID
- // This allows api.remove("removableInternal") to remove "removableInternal_abc123"
- // Walk from the end to prefer the most recently registered module when multiple match,
- // as stale entries from prior add/remove cycles may linger due to async lazy materialization.
const registeredModules = Array.from(this.slothlet.handlers.ownership.moduleToPath.keys());
let matchingModule = null;
+
+ // Recover the base moduleID from the argument. The argument is either a plain moduleID (a user
+ // id — which can never contain the reserved separator, refused at add()) or the internal
+ // composite `moduleIDapiPath` (e.g. a leaf's `__metadata.moduleID`). Splitting on the
+ // reserved separator is therefore unambiguous: it yields the base for a composite and the whole
+ // id otherwise. A ':' — or any other character — in a user id is preserved intact (#303).
+ const candidateModuleID = pathOrModuleId.split(MODULE_ID_SEPARATOR)[0];
+
+ // Match the base verbatim, or its auto-generated "_" form — this allows
+ // api.remove("removableInternal") to remove "removableInternal_abc123". Walk from the end to
+ // prefer the most recently registered module when multiple match, as stale entries from prior
+ // add/remove cycles may linger due to async lazy materialization.
for (let i = registeredModules.length - 1; i >= 0; i--) {
const candidate = registeredModules[i];
if (candidate === candidateModuleID || candidate.startsWith(`${candidateModuleID}_`)) {
@@ -2385,6 +2448,10 @@ export class ApiManager extends ComponentBase {
if (matchingModule) {
// Found a moduleID match
moduleID = matchingModule;
+ } else if (scopedApiPath !== null) {
+ // remove(moduleID, apiPath): the first argument must be a known moduleID — there is nothing
+ // to scope a removal to otherwise.
+ return false;
} else {
// No moduleID match, check if it's a valid API path
const owner = this.slothlet.handlers.ownership.getCurrentOwner(pathOrModuleId);
@@ -2398,10 +2465,12 @@ export class ApiManager extends ComponentBase {
}
}
} else {
- // No ownership tracking - use old heuristic (dots = apiPath)
+ // No ownership tracking - use old heuristic (dots = apiPath). Recover the base by splitting on
+ // the reserved separator: it strips an internal composite while preserving a plain id (which
+ // cannot contain the separator), so a ':' in a user id is never truncated (#303).
const isModuleId = !pathOrModuleId.includes(".");
apiPath = isModuleId ? null : pathOrModuleId;
- moduleID = isModuleId ? pathOrModuleId.split(":")[0] : null;
+ moduleID = isModuleId ? pathOrModuleId.split(MODULE_ID_SEPARATOR)[0] : null;
}
if (!this.slothlet || !this.slothlet.isLoaded) {
throw new this.SlothletError("INVALID_CONFIG_NOT_LOADED", {
@@ -2410,6 +2479,91 @@ export class ApiManager extends ComponentBase {
});
}
+ // Two-argument scoping: the moduleID resolved above must actually own the requested path, else there
+ // is nothing to remove. When it does, remove only THIS module's ownership across the path's subtree.
+ if (scopedApiPath !== null) {
+ const ownership = this.slothlet.handlers.ownership;
+ const normalizedScoped = this.normalizeApiPath(scopedApiPath).apiPath;
+ const ownedPaths = ownership?.moduleToPath?.get(moduleID);
+ if (!ownedPaths || !ownedPaths.has(normalizedScoped)) {
+ return false;
+ }
+ // Scoped two-argument removal is recursive but pinned to THIS module: walk every path this
+ // module owns at or under scopedApiPath and, for each, remove it (when this module was its sole
+ // owner) or revert it to the module that owned it before (when the node is shared). A node still
+ // owned by another module is left in place. A container node is deleted only once nothing remains
+ // under it — so it survives as long as it still holds another module's descendants. It never
+ // blanket-deletes the subtree; that is what remove(apiPath) (path as the first argument) is for.
+ const scopedModuleIDKey = String(moduleID);
+ const scopedPrefix = `${normalizedScoped}.`;
+ // Deepest-first so a container is reached only after its own leaves are gone.
+ const targets = [...ownedPaths]
+ .filter((p) => p === normalizedScoped || p.startsWith(scopedPrefix))
+ .sort((a, b) => b.length - a.length);
+ // When this scoped removal empties the module (it owns nothing outside scopedApiPath), block it
+ // from further registration BEFORE the walk. Tearing down a lazy node materializes it to delete it,
+ // and that materialization can register previously-unregistered descendants (a lazy submodule's
+ // leaves — e.g. shop.a.interop's) AFTER `targets` was computed; on a reload replay those late
+ // registrations leak back and resurrect the removed subtree. Marking the module unregistered up
+ // front makes ownership.register() reject them, so the removal is complete and stays gone across a
+ // reload. Only for a full removal — a partial one keeps sibling paths outside scopedApiPath that
+ // must still be able to materialize, so it is not blocked.
+ const isFullRemoval = [...ownedPaths].every((p) => p === normalizedScoped || p.startsWith(scopedPrefix));
+ if (isFullRemoval) {
+ ownership?.markUnregistered?.(scopedModuleIDKey);
+ }
+ for (const target of targets) {
+ const targetParts = this.normalizeApiPath(target).parts;
+ const scopedResult = ownership.removePath(target, scopedModuleIDKey);
+ if (scopedResult.action === "restore") {
+ // Shared node: revert the tree value to the owner it fell back to.
+ const revertValue = ownership.getCurrentValue?.(target);
+ const revertOwner = ownership.getCurrentOwner?.(target)?.moduleID;
+ /* v8 ignore else */
+ if (revertValue !== undefined && revertOwner) {
+ const revertOptions = { mutateExisting: true, allowOverwrite: true, collisionMode: "replace", moduleID: revertOwner };
+ await this.setValueAtPath(this.slothlet.api, targetParts, revertValue, revertOptions);
+ await this.setValueAtPath(this.slothlet.boundApi, targetParts, revertValue, revertOptions);
+ } else {
+ // Unreachable defensive mirror of the single-node restore path: removePath only returns
+ // "restore" when an owner remains on the node's stack, and every ownership entry carries a
+ // concrete value (a leaf's callable, or a container's object), so getCurrentValue above is
+ // never undefined for a restored node — revertValue is always defined and this else never
+ // runs. Kept so the scoped path degrades the same way the single-node path would if that
+ // invariant were ever broken.
+ await this.restoreApiPath(target, scopedResult.restoreModuleId);
+ }
+ } else {
+ // action "delete": this module was the node's last owner (an owned target never resolves
+ // to "none"). Remove the node from the tree ONLY when nothing is registered under it. A
+ // container is often owned solely by whichever module created it, so another module's
+ // descendant can still live under a node this module was the last owner of — deleting it
+ // would take that foreign node with it. Leave such a container standing (now unowned) for
+ // its remaining descendants; a childless leaf/container is deleted.
+ const stillHasChild = [...ownership.moduleToPath.values()].some((set) => {
+ for (const owned of set) if (owned.startsWith(`${target}.`)) return true;
+ return false;
+ });
+ if (!stillHasChild) {
+ await this.deletePath(this.slothlet.api, targetParts);
+ await this.deletePath(this.slothlet.boundApi, targetParts);
+ }
+ }
+ }
+ // The apiPath branch (unlike the moduleID branch) does not call ownership.unregister(), so sweep
+ // any cache entry the removed nodes orphaned.
+ this.#sweepOrphanedCaches();
+ // Record a SCOPED remove for reload replay — carrying both the moduleID and the apiPath so the
+ // replay re-runs it as the two-argument form, not a whole-path removal. This is safe because a
+ // module's id survives a reload: the "add" op records `options.moduleID` and it is reused on
+ // re-add (user-supplied ids AND auto-generated `_` ids alike), so the same id exists
+ // when this op replays. `scopedModuleID` is what distinguishes it from a plain apiPath remove.
+ if (recordHistory) {
+ this.state.operationHistory.push({ type: "remove", apiPath: normalizedScoped, scopedModuleID: scopedModuleIDKey });
+ }
+ return true;
+ }
+
if (apiPath && moduleID) {
const normalizedPath = this.normalizeApiPath(apiPath).apiPath;
const moduleIDKey = String(moduleID);
@@ -2461,12 +2615,20 @@ export class ApiManager extends ComponentBase {
});
return true;
}
- // The "restore" and "none+no-history" ownership actions in the apiPath+moduleID path are
- // never triggered in tests \u2014 removeApi with both arguments always hits "delete" action.
- /* v8 ignore start */
+ // In this branch moduleID is always the path's current owner (resolved from getCurrentOwner during
+ // detection), so removePath returns "delete" (single owner) or "restore" (a shadow remains) — never
+ // "none". The "delete" arm above and the "restore" primary below are both covered by the
+ // stacked-remove-by-apiPath tests; only the restore fallback and the none/return-false tail are
+ // unreachable defensive handlers, ignored precisely below. The action is always "restore" once
+ // "delete" is handled above and "none" is impossible here, so this if's else-arm never runs.
+ /* v8 ignore else */
if (ownershipResult.action === "restore") {
const restoredValue = this.slothlet.handlers.ownership?.getCurrentValue?.(normalizedPath);
const restoredModuleId = this.slothlet.handlers.ownership?.getCurrentOwner?.(normalizedPath)?.moduleID;
+ // getCurrentValue always resolves a concrete value for a restored node (a remaining stack owner
+ // carrying a leaf callable or a container object), so the else — the restoreApiPath fallback —
+ // never runs. `v8 ignore else` drops only that dead arm while keeping the covered if-body counted.
+ /* v8 ignore else */
if (restoredValue !== undefined && restoredModuleId) {
await this.setValueAtPath(this.slothlet.api, pathParts, restoredValue, {
mutateExisting: true,
@@ -2486,15 +2648,15 @@ export class ApiManager extends ComponentBase {
apiPath: normalizedPath
});
return true;
+ } else {
+ await this.restoreApiPath(normalizedPath, ownershipResult.restoreModuleId);
+ this.state.operationHistory.push({ type: "remove", apiPath: normalizedPath });
+ return true;
}
- await this.restoreApiPath(normalizedPath, ownershipResult.restoreModuleId);
- // Track in operation history for reload replay
- this.state.operationHistory.push({
- type: "remove",
- apiPath: normalizedPath
- });
- return true;
}
+ // Unreachable tail: moduleID is the path's current owner (above), so removePath never yields
+ // "none" here and the function never falls through — both are defensive only.
+ /* v8 ignore start */
if (ownershipResult.action === "none" && history.length === 0) {
await this.deletePath(this.slothlet.api, pathParts);
await this.deletePath(this.slothlet.boundApi, pathParts);
@@ -2772,7 +2934,10 @@ export class ApiManager extends ComponentBase {
// Path doesn't exist - nothing to remove
return false;
}
- // Ownership "delete"/"restore" actions require collision ownership tracking; tests never trigger these paths.
+ // Unreachable: this apiPath-only branch (moduleID resolved to null) is entered only when the ownership
+ // handler is absent — with a handler present, a plain apiPath resolves to its current owner and takes
+ // the apiPath+moduleID branch above. An absent handler makes removePath yield action "none" (handled
+ // just above), so "delete"/"restore" never fire here. Defensive parity with the owner-tracked branches.
/* v8 ignore start */
if (ownershipResult.action === "delete") {
await this.deletePath(this.slothlet.api, parts);
diff --git a/src/lib/handlers/metadata.mjs b/src/lib/handlers/metadata.mjs
index ec04aaf4..a30e8278 100644
--- a/src/lib/handlers/metadata.mjs
+++ b/src/lib/handlers/metadata.mjs
@@ -22,6 +22,18 @@ import { ComponentBase } from "#factories/component-base";
import { resolveWrapper } from "#handlers/unified-wrapper";
import { verifyToken } from "#handlers/lifecycle-token";
+/**
+ * Internal delimiter joining a module's id and apiPath in the composite `moduleID` metadata key
+ * (`` `${moduleID}${MODULE_ID_SEPARATOR}${apiPath}` ``). Deliberately a readable, slothlet-branded
+ * multi-character token rather than a lone `:` — a `:` is common in real module ids (a `vine:abc`
+ * namespacing convention, the internal `versionDispatcher:` id), and using it as the splitter
+ * made those ids un-round-trippable. A moduleID containing this token is refused at `add()`, so the
+ * composite is always unambiguous to split.
+ * @type {string}
+ * @internal
+ */
+export const MODULE_ID_SEPARATOR = "__slothlet_sep__";
+
/**
* Metadata handler for introspection of function metadata
* @class Metadata
@@ -237,11 +249,15 @@ export class Metadata extends ComponentBase {
return;
}
- // Construct full moduleID as "moduleID:apiPath/with/slashes"
+ // Construct the composite moduleID as `${moduleID}${MODULE_ID_SEPARATOR}${apiPath/with/slashes}`.
+ // It stays the per-path user-metadata key; the raw base id is also stored separately (baseModuleID)
+ // so consumers recover the owning module without splitting. The separator is a reserved token no
+ // moduleID may contain (enforced at add()), so a ':' — or any other character — is free in a
+ // module id and the composite is always unambiguous to split (#303).
let fullModuleID = systemData.moduleID;
if (systemData.apiPath && systemData.moduleID) {
const apiPathSlashes = systemData.apiPath.replace(/\./g, "/");
- fullModuleID = `${systemData.moduleID}:${apiPathSlashes}`;
+ fullModuleID = `${systemData.moduleID}${MODULE_ID_SEPARATOR}${apiPathSlashes}`;
}
// Derive sourceFolder from filePath if not provided
@@ -258,6 +274,8 @@ export class Metadata extends ComponentBase {
sourceFolder: sourceFolder,
apiPath: systemData.apiPath,
moduleID: fullModuleID,
+ // Raw owning-module id, verbatim — the colon-safe source for base recovery (#303).
+ baseModuleID: systemData.moduleID,
taggedAt: Date.now()
});
diff --git a/src/lib/handlers/ownership.mjs b/src/lib/handlers/ownership.mjs
index 48e2cca5..f1b669f3 100644
--- a/src/lib/handlers/ownership.mjs
+++ b/src/lib/handlers/ownership.mjs
@@ -223,6 +223,30 @@ export class OwnershipManager extends ComponentBase {
return { removed, rolledBack };
}
+ /**
+ * Block a moduleID from any further path registration without removing its current paths.
+ *
+ * @param {string} moduleID - Module to block from re-registration.
+ * @returns {void}
+ * @public
+ *
+ * @description
+ * Sets the same async-race guard {@link OwnershipManager#unregister} sets, but standalone: the scoped
+ * `remove(moduleID, apiPath)` path detaches nodes one at a time via {@link OwnershipManager#removePath}
+ * and never calls `unregister`. Tearing down a lazy node materializes it, and that materialization can
+ * register previously-unregistered descendants (a lazy submodule's leaves) AFTER the removal's target
+ * list was computed — which, on a reload replay, leak back and resurrect the removed subtree. When a
+ * scoped removal empties a module, call this BEFORE the walk so those late registrations are rejected
+ * (register() returns null for a module in this set). Cleared on the next {@link OwnershipManager#clear}
+ * (reload). Only for a full removal — a partial one keeps sibling paths that must still materialize.
+ *
+ * @example
+ * ownership.markUnregistered("plugins-core");
+ */
+ markUnregistered(moduleID) {
+ this._unregisteredModules.add(moduleID);
+ }
+
/**
* @param {string} apiPath - API path to modify.
* @param {string|null} [moduleID=null] - Module to remove (defaults to current owner).
diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs
index b4d2b332..b91686dc 100644
--- a/src/lib/handlers/unified-wrapper.mjs
+++ b/src/lib/handlers/unified-wrapper.mjs
@@ -1206,7 +1206,7 @@ export class UnifiedWrapper extends ComponentBase {
// caller now passes a string moduleID (or null) — the stale-signature caller that passed
// the slothlet instance was fixed in #274 (a7a711f), so the former object-coercion guard
// here is dead and was removed with it.
- const extractedModuleId = moduleID || (wrapperMetadata?.moduleID ? wrapperMetadata.moduleID.split(":")[0] : null);
+ const extractedModuleId = moduleID || wrapperMetadata?.baseModuleID || null;
this.slothlet.handlers.lifecycle.emit("impl:changed", {
apiPath: this.____slothletInternal.apiPath,
@@ -2016,18 +2016,19 @@ export class UnifiedWrapper extends ComponentBase {
}
}
- // moduleID: always prefer the PARENT/build owner (extract the SHORT id from the
- // "moduleID:apiPath" form). One buildAPI() builds exactly one module's subtree, so a child
+ // moduleID: always prefer the PARENT/build owner (its raw base id, read from baseModuleID).
+ // One buildAPI() builds exactly one module's subtree, so a child
// VALUE shared from another mount (e.g. eager+browser re-mounting a base leaf — same function
// object, still carrying base's metadata) must be owned by THIS mount's module. The previous
// code used the child VALUE's own moduleID whenever it carried its own metadata, which
// attributed re-mounted base leaves to base_slothlet and made api.remove() roll them back
// instead of deleting them (impl:removed never fired).
- if (parentMetadata?.moduleID) {
- const colonIndex = parentMetadata.moduleID.indexOf(":");
- // `colonIndex > 0` is always true because moduleIDs use "id:apiPath" format; no-colon fallback is unreachable.
- /* v8 ignore next */
- childModuleId = colonIndex > 0 ? parentMetadata.moduleID.substring(0, colonIndex) : parentMetadata.moduleID;
+ if (parentMetadata?.baseModuleID) {
+ // The raw base id, stored verbatim — read directly rather than recovered from the composite
+ // metadata tag. The composite joins the id and apiPath with the reserved MODULE_ID_SEPARATOR
+ // specifically so an id may contain any character (a user `vine:abc` convention, an internal
+ // `versionDispatcher:` id) without being truncated on recovery (#303).
+ childModuleId = parentMetadata.baseModuleID;
}
const childSourceFolder = childExistingMetadata?.sourceFolder || parentMetadata?.sourceFolder || null;
diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json
index 702a617a..662e45c6 100644
--- a/src/lib/i18n/languages/de-de.json
+++ b/src/lib/i18n/languages/de-de.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "Reservierte Namen (INTERNAL_KEYS wie _materialize, _impl) sind die eigenen Wrapper-Handles des Frameworks; eine gleichnamige Moduldatei würde sie bei der Child-Adoption überschreiben. Benennen Sie die Datei um.",
"MODULE_RESERVED_EXPORT": "Der Modul-Export '{name}' trägt den Namen eines framework-reservierten Schlüssels und kann nicht geladen werden.",
"HINT_MODULE_RESERVED_EXPORT": "Reservierte Namen (_materialize, __impl, ...) sind die eigenen Wrapper-Handles des Frameworks — ein solcher Export könnte nur verdeckt und unerreichbar sein. Benennen Sie den Export um.",
+ "MODULE_ID_RESERVED_SEPARATOR": "Die Modul-ID '{moduleID}' enthält das reservierte Trennzeichen '{separator}' und kann nicht verwendet werden.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet verbindet die ID und den API-Pfad eines Moduls intern mit diesem Token; eine Modul-ID, die es enthält, würde diesen Schlüssel beschädigen. Wählen Sie eine andere ID.",
"MODULE_IMPORT_FAILED": "Fehler beim Importieren des Moduls '{modulePath}': {error}. Überprüfen Sie, ob die Datei existiert und eine gültige Syntax hat.",
"HINT_MODULE_IMPORT_FAILED": "Stellen Sie sicher, dass die Moduldatei existiert und importiert werden kann. Prüfen Sie auf Syntaxfehler oder fehlende Abhängigkeiten.",
"CONTEXT_ALREADY_EXISTS": "Kontext für Instanz '{instanceID}' existiert bereits. Initialisierung kann nicht zweimal erfolgen.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "Pfad-Segment existiert nicht oder kann nicht durchlaufen werden",
"API_PATH_REASON_REQUIRED": "apiPath ist für die removeApi Operation erforderlich",
"API_PATH_REASON_VERSIONED_ROOT": "Versionierte API-Komponenten können nicht am Root-Pfad eingehängt werden; geben Sie einen punkt-separierten Pfad an, z. B. 'auth' oder 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "enthält das reservierte interne Trennzeichen, das in einem API-Pfad nicht zulässig ist",
"INVALID_API_PATH": "Ungültiger API-Pfad: '{apiPath}' existiert nicht in der geladenen API.",
"HINT_INVALID_API_PATH": "Stellen Sie sicher, dass der API-Pfad existiert, bevor Sie ihn benutzen. Benutzen Sie einen dot-separierten Pfad (z.B. 'math.add'), der auf ein aktuell geladenes Modul verweist.",
"HOOKS_NOT_INITIALIZED": "Zugriff auf Hook-Funktionalität nicht möglich: Der Hook-Manager ist nicht initialisiert.",
diff --git a/src/lib/i18n/languages/en-gb.json b/src/lib/i18n/languages/en-gb.json
index 00d718cf..ac8368e1 100644
--- a/src/lib/i18n/languages/en-gb.json
+++ b/src/lib/i18n/languages/en-gb.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "Reserved names (INTERNAL_KEYS such as _materialize, _impl) are the framework's own wrapper handles; a module file by that name would overwrite them during child adoption. Rename the file.",
"MODULE_RESERVED_EXPORT": "Module export '{name}' is named for a framework-reserved key and cannot be loaded.",
"HINT_MODULE_RESERVED_EXPORT": "Reserved names (_materialize, __impl, ...) are the framework's own wrapper handles — such an export could only ever be shadowed and unreachable. Rename the export.",
+ "MODULE_ID_RESERVED_SEPARATOR": "Module ID '{moduleID}' contains the reserved separator '{separator}' and cannot be used.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet joins a module's id and api path with this token internally; a module id containing it would corrupt that key. Choose a different id.",
"MODULE_IMPORT_FAILED": "Failed to import module '{modulePath}': {error}. Check that the file exists and has valid syntax.",
"HINT_MODULE_IMPORT_FAILED": "Ensure the module file exists and can be imported. Check for syntax errors or missing dependencies.",
"CONTEXT_ALREADY_EXISTS": "Context for instance '{instanceID}' already exists. Cannot initialize twice.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "path segment does not exist or is not traversable",
"API_PATH_REASON_REQUIRED": "apiPath is required for removeApi operation",
"API_PATH_REASON_VERSIONED_ROOT": "versioned API components cannot be mounted at the root path; provide a dot-separated path such as 'auth' or 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "contains the reserved internal separator token, which is not permitted in an API path",
"INVALID_API_PATH": "Invalid API path: '{apiPath}' does not exist in the loaded API.",
"HINT_INVALID_API_PATH": "Ensure the API path exists before using it. Use a dot-separated path (e.g. 'math.add') that refers to a currently loaded module.",
"HOOKS_NOT_INITIALIZED": "Cannot access hook functionality: the hook manager is not initialized.",
diff --git a/src/lib/i18n/languages/en-us.json b/src/lib/i18n/languages/en-us.json
index 2ba93b98..b89bf768 100644
--- a/src/lib/i18n/languages/en-us.json
+++ b/src/lib/i18n/languages/en-us.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "Reserved names (INTERNAL_KEYS such as _materialize, _impl) are the framework's own wrapper handles; a module file by that name would overwrite them during child adoption. Rename the file.",
"MODULE_RESERVED_EXPORT": "Module export '{name}' is named for a framework-reserved key and cannot be loaded.",
"HINT_MODULE_RESERVED_EXPORT": "Reserved names (_materialize, __impl, ...) are the framework's own wrapper handles — such an export could only ever be shadowed and unreachable. Rename the export.",
+ "MODULE_ID_RESERVED_SEPARATOR": "Module ID '{moduleID}' contains the reserved separator '{separator}' and cannot be used.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet joins a module's id and api path with this token internally; a module id containing it would corrupt that key. Choose a different id.",
"MODULE_IMPORT_FAILED": "Failed to import module '{modulePath}': {error}. Check that the file exists and has valid syntax.",
"HINT_MODULE_IMPORT_FAILED": "Ensure the module file exists and can be imported. Check for syntax errors or missing dependencies.",
"CONTEXT_ALREADY_EXISTS": "Context for instance '{instanceID}' already exists. Cannot initialize twice.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "path segment does not exist or is not traversable",
"API_PATH_REASON_REQUIRED": "apiPath is required for removeApi operation",
"API_PATH_REASON_VERSIONED_ROOT": "versioned API components cannot be mounted at the root path; provide a dot-separated path such as 'auth' or 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "contains the reserved internal separator token, which is not permitted in an API path",
"INVALID_API_PATH": "Invalid API path: '{apiPath}' does not exist in the loaded API.",
"HINT_INVALID_API_PATH": "Ensure the API path exists before using it. Use a dot-separated path (e.g. 'math.add') that refers to a currently loaded module.",
"HOOKS_NOT_INITIALIZED": "Cannot access hook functionality: the hook manager is not initialized.",
diff --git a/src/lib/i18n/languages/es-es.json b/src/lib/i18n/languages/es-es.json
index 45f266b9..892aae79 100644
--- a/src/lib/i18n/languages/es-es.json
+++ b/src/lib/i18n/languages/es-es.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "Los nombres reservados (INTERNAL_KEYS como _materialize, _impl) son los manejadores internos del framework; un archivo de módulo con ese nombre los sobrescribiría durante la adopción de hijos. Renombra el archivo.",
"MODULE_RESERVED_EXPORT": "La exportación de módulo '{name}' lleva el nombre de una clave reservada del framework y no puede cargarse.",
"HINT_MODULE_RESERVED_EXPORT": "Los nombres reservados (_materialize, __impl, ...) son los manejadores internos del framework — tal exportación solo podría quedar oculta e inaccesible. Renombra la exportación.",
+ "MODULE_ID_RESERVED_SEPARATOR": "El ID de módulo '{moduleID}' contiene el separador reservado '{separator}' y no puede usarse.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet une internamente el ID y la ruta de API de un módulo con este token; un ID de módulo que lo contenga corrompería esa clave. Elige otro ID.",
"MODULE_IMPORT_FAILED": "Error al importar el módulo '{modulePath}': {error}. Comprueba que el fichero existe y tiene una sintaxis válida.",
"HINT_MODULE_IMPORT_FAILED": "Asegúrate de que el fichero del módulo existe y puede ser importado. Comprueba si hay errores de sintaxis o dependencias faltantes.",
"CONTEXT_ALREADY_EXISTS": "El contexto para la instancia '{instanceID}' ya existe. No se puede inicializar dos veces.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "el segmento de ruta no existe o no se puede recorrer",
"API_PATH_REASON_REQUIRED": "apiPath es obligatorio para la operación removeApi",
"API_PATH_REASON_VERSIONED_ROOT": "Los componentes API versionados no pueden montarse en la ruta raíz; proporciona una ruta separada por puntos como 'auth' o 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "contiene el token separador interno reservado, que no se permite en una ruta de API",
"INVALID_API_PATH": "Ruta de API inválida: '{apiPath}' no existe en la API cargada.",
"HINT_INVALID_API_PATH": "Asegúrate de que la ruta de la API existe antes de usarla. Usa una ruta separada por puntos (ej. 'math.add') que se refiera a un módulo cargado actualmente.",
"HOOKS_NOT_INITIALIZED": "No se puede acceder a la funcionalidad de ganchos: el gestor de ganchos no está inicializado.",
diff --git a/src/lib/i18n/languages/es-mx.json b/src/lib/i18n/languages/es-mx.json
index ae1b78d8..e5742f4f 100644
--- a/src/lib/i18n/languages/es-mx.json
+++ b/src/lib/i18n/languages/es-mx.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "Los nombres reservados (INTERNAL_KEYS como _materialize, _impl) son los manejadores internos del framework; un archivo de módulo con ese nombre los sobrescribiría durante la adopción de hijos. Renombra el archivo.",
"MODULE_RESERVED_EXPORT": "La exportación de módulo '{name}' lleva el nombre de una clave reservada del framework y no puede cargarse.",
"HINT_MODULE_RESERVED_EXPORT": "Los nombres reservados (_materialize, __impl, ...) son los manejadores internos del framework — tal exportación solo podría quedar oculta e inaccesible. Renombra la exportación.",
+ "MODULE_ID_RESERVED_SEPARATOR": "El ID de módulo '{moduleID}' contiene el separador reservado '{separator}' y no puede usarse.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet une internamente el ID y la ruta de API de un módulo con este token; un ID de módulo que lo contenga corrompería esa clave. Elige otro ID.",
"MODULE_IMPORT_FAILED": "Error al importar el módulo '{modulePath}': {error}. Compruebe que el archivo existe y tiene una sintaxis válida.",
"HINT_MODULE_IMPORT_FAILED": "Asegúrese de que el archivo del módulo existe y puede ser importado. Compruebe si hay errores de sintaxis o dependencias faltantes.",
"CONTEXT_ALREADY_EXISTS": "El contexto para la instancia '{instanceID}' ya existe. No se puede inicializar dos veces.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "el segmento de ruta no existe o no se puede recorrer",
"API_PATH_REASON_REQUIRED": "apiPath es obligatorio para la operación removeApi",
"API_PATH_REASON_VERSIONED_ROOT": "Los componentes API versionados no pueden montarse en la ruta raíz; proporcione una ruta separada por puntos como 'auth' o 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "contiene el token separador interno reservado, que no se permite en una ruta de API",
"INVALID_API_PATH": "Ruta de API inválida: '{apiPath}' no existe en la API cargada.",
"HINT_INVALID_API_PATH": "Asegúrese de que la ruta de la API existe antes de usarla. Use una ruta separada por puntos (ej. 'math.add') que se refiera a un módulo cargado actualmente.",
"HOOKS_NOT_INITIALIZED": "No se puede acceder a la funcionalidad de ganchos: el gestor de ganchos no está inicializado.",
diff --git a/src/lib/i18n/languages/fr-fr.json b/src/lib/i18n/languages/fr-fr.json
index 73a4a57f..ade24916 100644
--- a/src/lib/i18n/languages/fr-fr.json
+++ b/src/lib/i18n/languages/fr-fr.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "Les noms réservés (INTERNAL_KEYS comme _materialize, _impl) sont les poignées internes du framework ; un fichier de module portant ce nom les écraserait lors de l'adoption des enfants. Renommez le fichier.",
"MODULE_RESERVED_EXPORT": "L'export de module '{name}' porte le nom d'une clé réservée du framework et ne peut pas être chargé.",
"HINT_MODULE_RESERVED_EXPORT": "Les noms réservés (_materialize, __impl, ...) sont les poignées internes du framework — un tel export ne pourrait qu'être masqué et inaccessible. Renommez l'export.",
+ "MODULE_ID_RESERVED_SEPARATOR": "L'ID de module '{moduleID}' contient le séparateur réservé '{separator}' et ne peut pas être utilisé.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet joint en interne l'ID et le chemin d'API d'un module avec ce jeton ; un ID de module le contenant corromprait cette clé. Choisissez un autre ID.",
"MODULE_IMPORT_FAILED": "Échec de l'importation du module '{modulePath}' : {error}. Vérifiez que le fichier existe et possède une syntaxe valide.",
"HINT_MODULE_IMPORT_FAILED": "Assurez-vous que le fichier du module existe et peut être importé. Vérifiez les erreurs de syntaxe ou les dépendances manquantes.",
"CONTEXT_ALREADY_EXISTS": "Le contexte pour l'instance '{instanceID}' existe déjà. Impossible d'initialiser deux fois.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "le segment de chemin n'existe pas ou n'est pas traversable",
"API_PATH_REASON_REQUIRED": "apiPath est requis pour l'opération removeApi",
"API_PATH_REASON_VERSIONED_ROOT": "Les composants API versionnés ne peuvent pas être montés à la racine ; fournissez un chemin séparé par des points, tel que 'auth' ou 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "contient le jeton séparateur interne réservé, qui n'est pas autorisé dans un chemin d'API",
"INVALID_API_PATH": "Chemin d'API invalide : '{apiPath}' n'existe pas dans l'API chargée.",
"HINT_INVALID_API_PATH": "Assurez-vous que le chemin d'API existe avant de l'utiliser. Utilisez un chemin séparé par des points (ex : 'math.add') qui fait référence à un module actuellement chargé.",
"HOOKS_NOT_INITIALIZED": "Impossible d'accéder aux fonctionnalités de hook : le gestionnaire de hook n'est pas initialisé.",
diff --git a/src/lib/i18n/languages/hi-in.json b/src/lib/i18n/languages/hi-in.json
index c1acee29..a7b9af46 100644
--- a/src/lib/i18n/languages/hi-in.json
+++ b/src/lib/i18n/languages/hi-in.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "आरक्षित नाम (INTERNAL_KEYS जैसे _materialize, _impl) फ्रेमवर्क के अपने रैपर हैंडल हैं; उसी नाम की मॉड्यूल फ़ाइल चाइल्ड अपनाने के दौरान उन्हें अधिलेखित कर देगी। फ़ाइल का नाम बदलें।",
"MODULE_RESERVED_EXPORT": "मॉड्यूल एक्सपोर्ट '{name}' फ्रेमवर्क-आरक्षित कुंजी के नाम पर है और लोड नहीं किया जा सकता।",
"HINT_MODULE_RESERVED_EXPORT": "आरक्षित नाम (_materialize, __impl, ...) फ्रेमवर्क के अपने रैपर हैंडल हैं — ऐसा एक्सपोर्ट केवल छिपा और अप्राप्य रह सकता है। एक्सपोर्ट का नाम बदलें।",
+ "MODULE_ID_RESERVED_SEPARATOR": "मॉड्यूल ID '{moduleID}' में आरक्षित विभाजक '{separator}' है और इसका उपयोग नहीं किया जा सकता।",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet आंतरिक रूप से इस टोकन से मॉड्यूल की ID और API पथ को जोड़ता है; इसे युक्त मॉड्यूल ID उस कुंजी को दूषित कर देगी। कोई दूसरी ID चुनें।",
"MODULE_IMPORT_FAILED": "मॉड्यूल '{modulePath}' आयात करने में विफल: {error}। जांचें कि फ़ाइल मौजूद है और इसमें मान्य सिंटैक्स है।",
"HINT_MODULE_IMPORT_FAILED": "सुनिश्चित करें कि मॉड्यूल फ़ाइल मौजूद है और आयात की जा सकती है। सिंटैक्स त्रुटियों या अनुपलब्ध निर्भरताओं की जांच करें।",
"CONTEXT_ALREADY_EXISTS": "इंस्टेंस '{instanceID}' के लिए संदर्भ पहले से मौजूद है। दो बार प्रारंभ नहीं किया जा सकता।",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "पथ खंड मौजूद नहीं है या पार करने योग्य नहीं है",
"API_PATH_REASON_REQUIRED": "removeApi ऑपरेशन के लिए apiPath आवश्यक है",
"API_PATH_REASON_VERSIONED_ROOT": "संस्करणित API घटकों को रूट पथ पर माउंट नहीं किया जा सकता, इसके बजाय 'auth' या 'v1.auth' जैसा डॉट-अलग पथ प्रदान करें",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "आरक्षित आंतरिक विभाजक टोकन शामिल है, जो API पथ में अनुमत नहीं है",
"INVALID_API_PATH": "अमान्य API पथ: लोड की गई API में '{apiPath}' मौजूद नहीं है।",
"HINT_INVALID_API_PATH": "उपयोग करने से पहले सुनिश्चित करें कि API पथ मौजूद है। डॉट-सेपरेटेड पथ (उदा. 'math.add') का उपयोग करें जो वर्तमान में लोड किए गए मॉड्यूल को संदर्भित करता है।",
"HOOKS_NOT_INITIALIZED": "हुक कार्यक्षमता तक नहीं पहुँचा जा सकता: हुक मैनेजर प्रारंभ नहीं किया गया है।",
diff --git a/src/lib/i18n/languages/ja-jp.json b/src/lib/i18n/languages/ja-jp.json
index dc6d515a..ccf30ab9 100644
--- a/src/lib/i18n/languages/ja-jp.json
+++ b/src/lib/i18n/languages/ja-jp.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "予約名 (INTERNAL_KEYS の _materialize、_impl など) はフレームワーク自身のラッパーハンドルです。同名のモジュールファイルは子の取り込み時にそれらを上書きしてしまいます。ファイル名を変更してください。",
"MODULE_RESERVED_EXPORT": "モジュールのエクスポート '{name}' はフレームワーク予約キーの名前を持つため読み込めません。",
"HINT_MODULE_RESERVED_EXPORT": "予約名 (_materialize、__impl など) はフレームワーク自身のラッパーハンドルです — そのようなエクスポートは隠されて到達不能になるだけです。エクスポート名を変更してください。",
+ "MODULE_ID_RESERVED_SEPARATOR": "モジュールID '{moduleID}' は予約された区切り文字 '{separator}' を含んでいるため使用できません。",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet は内部でこのトークンを使ってモジュールのIDとAPIパスを連結します。これを含むモジュールIDはそのキーを破損させます。別のIDを選んでください。",
"MODULE_IMPORT_FAILED": "モジュール '{modulePath}' のインポートに失敗しました: {error}。ファイルが存在し、構文が正しいか確認してください。",
"HINT_MODULE_IMPORT_FAILED": "モジュールファイルが存在し、インポート可能であることを確認してください。構文エラーや依存関係の不足がないかを確認してください。",
"CONTEXT_ALREADY_EXISTS": "インスタンス '{instanceID}' のコンテキストは既に存在します。2回初期化することはできません。",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "パスセグメントが存在しないか、探索不可能です",
"API_PATH_REASON_REQUIRED": "removeApi 操作には apiPath が必要です",
"API_PATH_REASON_VERSIONED_ROOT": "バージョン管理された API コンポーネントはルートパスにマウントできません。'auth' や 'v1.auth' のようなドット区切りのパスを指定してください",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "予約された内部区切りトークンを含んでおり、API パスでは使用できません",
"INVALID_API_PATH": "無効な API パス: '{apiPath}' は読み込まれた API 内に存在しません。",
"HINT_INVALID_API_PATH": "API パスを使用する前に存在することを確認してください。現在読み込まれているモジュールを参照するドット区切りのパス (例: 'math.add') を使用してください。",
"HOOKS_NOT_INITIALIZED": "フック機能にアクセスできません: フックマネージャーが初期化されていません。",
diff --git a/src/lib/i18n/languages/ko-kr.json b/src/lib/i18n/languages/ko-kr.json
index 24d9d22d..33fd0075 100644
--- a/src/lib/i18n/languages/ko-kr.json
+++ b/src/lib/i18n/languages/ko-kr.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "예약 이름 (INTERNAL_KEYS의 _materialize, _impl 등)은 프레임워크 자체의 래퍼 핸들입니다. 같은 이름의 모듈 파일은 자식 채택 시 이를 덮어씁니다. 파일 이름을 변경하세요.",
"MODULE_RESERVED_EXPORT": "모듈 내보내기 '{name}'은(는) 프레임워크 예약 키의 이름이므로 로드할 수 없습니다.",
"HINT_MODULE_RESERVED_EXPORT": "예약 이름 (_materialize, __impl 등)은 프레임워크 자체의 래퍼 핸들입니다 — 이런 내보내기는 가려져 도달할 수 없을 뿐입니다. 내보내기 이름을 변경하세요.",
+ "MODULE_ID_RESERVED_SEPARATOR": "모듈 ID '{moduleID}'에 예약된 구분자 '{separator}'가 포함되어 있어 사용할 수 없습니다.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet은 내부적으로 이 토큰으로 모듈의 ID와 API 경로를 연결합니다. 이를 포함하는 모듈 ID는 해당 키를 손상시킵니다. 다른 ID를 선택하세요.",
"MODULE_IMPORT_FAILED": "모듈 '{modulePath}' 가져오기 실패: {error}. 파일이 존재하고 유효한 구문인지 확인하십시오.",
"HINT_MODULE_IMPORT_FAILED": "모듈 파일이 존재하고 가져올 수 있는지 확인하십시오. 구문 오류나 누락된 종속성을 확인하십시오.",
"CONTEXT_ALREADY_EXISTS": "인스턴스 '{instanceID}'의 컨텍스트가 이미 존재합니다. 두 번 초기화할 수 없습니다.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "경로 세그먼트가 존재하지 않거나 탐색할 수 없음",
"API_PATH_REASON_REQUIRED": "removeApi 작업에 apiPath가 필요함",
"API_PATH_REASON_VERSIONED_ROOT": "버전이 지정된 API 컴포넌트는 루트 경로에 마운트할 수 없습니다. 'auth' 또는 'v1.auth'와 같이 점으로 구분된 경로를 제공하세요",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "예약된 내부 구분자 토큰을 포함하고 있어 API 경로에 사용할 수 없습니다",
"INVALID_API_PATH": "유효하지 않은 API 경로: 로드된 API에 '{apiPath}'이(가) 존재하지 않습니다.",
"HINT_INVALID_API_PATH": "API 경로가 사용 전에 존재하는지 확인하십시오. 현재 로드된 모듈을 가리키는 점 구분 경로(예: 'math.add')를 사용하십시오.",
"HOOKS_NOT_INITIALIZED": "후크 기능에 액세스할 수 없습니다: 후크 관리자가 초기화되지 않았습니다.",
diff --git a/src/lib/i18n/languages/pt-br.json b/src/lib/i18n/languages/pt-br.json
index ced94f40..c8e1b95b 100644
--- a/src/lib/i18n/languages/pt-br.json
+++ b/src/lib/i18n/languages/pt-br.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "Nomes reservados (INTERNAL_KEYS como _materialize, _impl) são os manipuladores internos do framework; um arquivo de módulo com esse nome os sobrescreveria durante a adoção de filhos. Renomeie o arquivo.",
"MODULE_RESERVED_EXPORT": "A exportação de módulo '{name}' tem o nome de uma chave reservada do framework e não pode ser carregada.",
"HINT_MODULE_RESERVED_EXPORT": "Nomes reservados (_materialize, __impl, ...) são os manipuladores internos do framework — tal exportação só poderia ficar oculta e inacessível. Renomeie a exportação.",
+ "MODULE_ID_RESERVED_SEPARATOR": "O ID do módulo '{moduleID}' contém o separador reservado '{separator}' e não pode ser usado.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet une internamente o ID e o caminho de API de um módulo com este token; um ID de módulo que o contenha corromperia essa chave. Escolha outro ID.",
"MODULE_IMPORT_FAILED": "Falha ao importar o módulo '{modulePath}': {error}. Verifique se o arquivo existe e possui sintaxe válida.",
"HINT_MODULE_IMPORT_FAILED": "Certifique-se de que o arquivo do módulo existe e pode ser importado. Verifique se há erros de sintaxe ou dependências ausentes.",
"CONTEXT_ALREADY_EXISTS": "O contexto para a instância '{instanceID}' já existe. Não é possível inicializar duas vezes.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "o segmento do caminho não existe ou não é atravessável",
"API_PATH_REASON_REQUIRED": "apiPath é obrigatório para a operação removeApi",
"API_PATH_REASON_VERSIONED_ROOT": "Componentes de API versionados não podem ser montados no caminho raiz; forneça um caminho separado por pontos como 'auth' ou 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "contém o token separador interno reservado, que não é permitido em um caminho de API",
"INVALID_API_PATH": "Caminho de API inválido: '{apiPath}' não existe na API carregada.",
"HINT_INVALID_API_PATH": "Certifique-se de que o caminho da API existe antes de usá-lo. Use um caminho separado por pontos (ex: 'math.add') que se refira a um módulo carregado no momento.",
"HOOKS_NOT_INITIALIZED": "Não é possível acessar a funcionalidade de gancho (hook): o gerenciador de ganchos não foi inicializado.",
diff --git a/src/lib/i18n/languages/ru-ru.json b/src/lib/i18n/languages/ru-ru.json
index 50375723..8a983140 100644
--- a/src/lib/i18n/languages/ru-ru.json
+++ b/src/lib/i18n/languages/ru-ru.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "Зарезервированные имена (INTERNAL_KEYS, такие как _materialize, _impl) — это собственные обработчики оболочки фреймворка; файл модуля с таким именем перезаписал бы их при принятии потомков. Переименуйте файл.",
"MODULE_RESERVED_EXPORT": "Экспорт модуля '{name}' назван по зарезервированному ключу фреймворка и не может быть загружен.",
"HINT_MODULE_RESERVED_EXPORT": "Зарезервированные имена (_materialize, __impl, ...) — это собственные обработчики оболочки фреймворка — такой экспорт может быть лишь скрыт и недостижим. Переименуйте экспорт.",
+ "MODULE_ID_RESERVED_SEPARATOR": "Идентификатор модуля '{moduleID}' содержит зарезервированный разделитель '{separator}' и не может использоваться.",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet внутренне объединяет идентификатор модуля и путь API этим токеном; идентификатор модуля, содержащий его, повредит этот ключ. Выберите другой идентификатор.",
"MODULE_IMPORT_FAILED": "Не удалось импортировать модуль '{modulePath}': {error}. Проверьте, существует ли файл и корректен ли синтаксис.",
"HINT_MODULE_IMPORT_FAILED": "Проверьте существование файла и корректность импорта. Проверьте зависимости.",
"CONTEXT_ALREADY_EXISTS": "Контекст для экземпляра '{instanceID}' уже существует. Невозможно инициализировать дважды.",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "Сегмент пути не существует или не может быть пройден",
"API_PATH_REASON_REQUIRED": "apiPath обязателен для операции removeApi",
"API_PATH_REASON_VERSIONED_ROOT": "Версионированные API-компоненты нельзя монтировать в корневом пути; укажите путь через точку, например 'auth' или 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "содержит зарезервированный внутренний разделитель, который недопустим в пути API",
"INVALID_API_PATH": "Неверный API-путь: '{apiPath}' не существует в загруженной API.",
"HINT_INVALID_API_PATH": "Убедитесь, что путь API существует перед использованием. Используйте точечную нотацию, напр. 'math.add'.",
"HOOKS_NOT_INITIALIZED": "Невозможно использовать hooks: менеджер hook не инициализирован.",
diff --git a/src/lib/i18n/languages/zh-cn.json b/src/lib/i18n/languages/zh-cn.json
index 9136c9ab..39e6bfe8 100644
--- a/src/lib/i18n/languages/zh-cn.json
+++ b/src/lib/i18n/languages/zh-cn.json
@@ -63,6 +63,8 @@
"HINT_MODULE_RESERVED_FILENAME": "保留名称(INTERNAL_KEYS 中的 _materialize、_impl 等)是框架自身的包装器句柄;同名模块文件会在子级收养时覆盖它们。请重命名该文件。",
"MODULE_RESERVED_EXPORT": "模块导出 '{name}' 使用了框架保留键的名称,无法加载。",
"HINT_MODULE_RESERVED_EXPORT": "保留名称(_materialize、__impl 等)是框架自身的包装器句柄 — 这样的导出只会被遮蔽且不可达。请重命名该导出。",
+ "MODULE_ID_RESERVED_SEPARATOR": "模块 ID '{moduleID}' 包含保留分隔符 '{separator}',无法使用。",
+ "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet 在内部用此标记连接模块的 ID 和 API 路径;包含它的模块 ID 会破坏该键。请选择其他 ID。",
"MODULE_IMPORT_FAILED": "导入模块 '{modulePath}' 失败:{error}。请检查文件是否存在且语法正确。",
"HINT_MODULE_IMPORT_FAILED": "请确保模块文件存在且可导入。检查语法错误或缺失的依赖项。",
"CONTEXT_ALREADY_EXISTS": "实例 '{instanceID}' 的上下文已存在。不能初始化两次。",
@@ -382,6 +384,7 @@
"API_PATH_REASON_NOT_TRAVERSABLE": "路径段不存在或不可遍历",
"API_PATH_REASON_REQUIRED": "removeApi 操作需要指定 apiPath",
"API_PATH_REASON_VERSIONED_ROOT": "已版本化的 API 组件不能挂载在根路径下;请提供以点分隔的路径,例如 'auth' 或 'v1.auth'",
+ "API_PATH_REASON_RESERVED_SEPARATOR": "包含保留的内部分隔符标记,不允许出现在 API 路径中",
"INVALID_API_PATH": "无效的 API 路径:'{apiPath}' 在已加载的 API 中不存在。",
"HINT_INVALID_API_PATH": "在使用前确保 API 路径存在。使用点分写法(例如 'math.add')指向当前已加载的模块。",
"HOOKS_NOT_INITIALIZED": "无法访问钩子功能:钩子管理器未初始化。",
diff --git a/src/slothlet.mjs b/src/slothlet.mjs
index c2267840..3c3265a1 100644
--- a/src/slothlet.mjs
+++ b/src/slothlet.mjs
@@ -926,10 +926,13 @@ class Slothlet {
await this.handlers.apiManager.addApiComponent({
apiPath: operation.apiPath,
folderPath: operation.folderPath,
+ // operation.options carries the ORIGINAL moduleID recorded at add time; addApiComponent
+ // reuses it (there is no top-level moduleID param), so a module keeps the same id across a
+ // reload — for user-supplied ids and auto-generated `_` ids alike. That stable
+ // id is what lets a scoped remove op below resolve on replay.
// operation.options is always provided during api.add() replay; {} fallback is dead code.
/* v8 ignore next */
options: { ...(operation.options || {}), recordHistory: false },
- moduleID: `replay_${this.helpers.utilities.generateId().substring(0, 8)}`, // Generate new moduleID for replay
versionConfig: operation.versionConfig || null
});
} else if (operation.type === "remove") {
@@ -939,7 +942,17 @@ class Slothlet {
// A bare deletePath would prune only `this.api` and leave boundApi / cache /
// ownership remnants, so the rebuilt tree would not match a clean
// build-up-to-this-point. recordHistory:false avoids re-appending the op.
- await this.handlers.apiManager.removeApiComponent(operation.apiPath, { recordHistory: false });
+ if (operation.scopedModuleID) {
+ // Scoped removal (remove(moduleID, apiPath)): replay the two-argument form so only this
+ // module's nodes go. The module's id survives the add replay above, so it resolves; a
+ // plain apiPath removal here would over-delete other modules sharing the path.
+ await this.handlers.apiManager.removeApiComponent(operation.scopedModuleID, {
+ scopedApiPath: operation.apiPath,
+ recordHistory: false
+ });
+ } else {
+ await this.handlers.apiManager.removeApiComponent(operation.apiPath, { recordHistory: false });
+ }
} else if (operation.type === "addPermissionRule") {
// permissionManager is always re-registered by load() before replay (slothletProperty); the absent-manager arm is unreachable.
/* v8 ignore else */
@@ -1500,7 +1513,7 @@ export default slothlet;
* @property {object} slothlet.api - Runtime API mutation methods — availability controlled by `api.mutations` config option.
* @property {Function} slothlet.api.add - Mount a new API module at runtime. %%sig: (apiPath: string, folderPath: string, [options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.api.add('utils.math', './api/utils/math');%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.api.add('utils.math', './api/utils/math');|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.api.add('utils.math', './api/utils/math');|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.api.add('utils.math', './api/utils/math');%%
* @property {Function} slothlet.api.reload - Hot-reload a specific module or directory path. %%sig: ([pathOrModuleId]: string|null, [options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|// Reload a specific module|await api.slothlet.api.reload('utils.math');|// Reload everything|await api.slothlet.api.reload();%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| // Reload a specific module| await api.slothlet.api.reload('utils.math');| // Reload everything| await api.slothlet.api.reload();|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| // Reload a specific module| await api.slothlet.api.reload('utils.math');| // Reload everything| await api.slothlet.api.reload();|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|// Reload a specific module|await api.slothlet.api.reload('utils.math');|// Reload everything|await api.slothlet.api.reload();%%
- * @property {Function} slothlet.api.remove - Unmount an API module at runtime. %%sig: (pathOrModuleId: string): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.api.remove('utils.math');%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.api.remove('utils.math');|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.api.remove('utils.math');|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.api.remove('utils.math');%%
+ * @property {Function} slothlet.api.remove - Unmount an API module at runtime. `remove(id)` removes every path the module owns; `remove(apiPath)` removes that path's whole subtree; the two-argument `remove(moduleID, apiPath)` removes only that module's single node at the path, leaving sibling modules sharing the mount and the module's other mounts intact. Resolves to whether anything was removed. %%sig: (pathOrModuleId: string, apiPath?: string): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.api.remove('utils.math');%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.api.remove('utils.math');|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.api.remove('utils.math');|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.api.remove('utils.math');%%
* @property {Function} slothlet.api.leaves - Enumerate the api paths a module owns, read from the loader's ownership records. Pass a moduleID, a mount endpoint, any owned path, or `"."` for the base load; `{ details: true }` returns every owned path tagged with its kind instead of the callable paths alone. The answer is scoped to the caller — module-private members the caller could not read are omitted; `{ includePrivate: true }` (host-only) returns the unredacted list, and a module caller passing it is refused with PERMISSION_DENIED. %%sig: (key: string, [options]: Object): Promise.<string[]|Array.<{path: string, kind: "function"|"namespace"|"data"}>>%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|const moduleID = await api.slothlet.api.add('shop', './ext/shop/api');|await api.slothlet.api.leaves(moduleID);%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.api.leaves('shop', { details: true });|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.api.leaves('shop');|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.api.leaves('shop');%%
* @property {object} slothlet.api.modules - Module discovery + mount sub-namespace. Composes subsystems shipped as separate npm packages (each with a `slothlet.module.json` manifest) into this api tree at runtime. Each method is typed as `Function` here; the detailed parameter / return shapes (`DiscoverOptions`, `DiscoverResult`, `AddModuleOptions`, `AddModulesOptions`, `MountResult`, `FailureEntry`) live in [`docs/MODULE-DISCOVERY.md`](../docs/MODULE-DISCOVERY.md), which also covers multi-version routing, error codes, and the `modules:*` lifecycle events.
* @property {Function} slothlet.api.modules.discover - Walk the filesystem for slothlet modules; replace the per-instance discovery cache and return the fresh results. %%sig: ([options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|const found = await api.slothlet.api.modules.discover({ scanRoot: process.cwd(), prefix: "@cldmv/packrat-driver-" });%%
diff --git a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs
new file mode 100644
index 00000000..f669382c
--- /dev/null
+++ b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs
@@ -0,0 +1,165 @@
+/**
+ * @Project: @cldmv/slothlet
+ * @Filename: /tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs
+ * @Date: 2026-08-24T00:00:00-08:00 (1756022400)
+ * @Author: Nate Corcoran
+ * @Email:
+ * -----
+ * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com)
+ * @Last modified time: 2026-08-24T00:00:00-08:00 (1756022400)
+ * -----
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
+ */
+
+/**
+ * @fileoverview A moduleID containing a colon must round-trip through add/leaves/remove/reload (#303).
+ *
+ * @description
+ * `:` was once slothlet's internal composite `moduleID:apiPath` separator, so a user-supplied moduleID
+ * containing a colon (e.g. a `vine:abc` namespaced convention) used to be un-removable: removeApiComponent
+ * split the argument on `:` and looked up only the first segment. The internal separator is now a reserved
+ * multi-character token (`MODULE_ID_SEPARATOR`) that a moduleID may not contain, so `:` — and every other
+ * character — is a free, fully round-trippable character in a module id.
+ *
+ * @module tests/vitests/suites/api-manager/api-manager-colon-module-id
+ */
+
+process.env.SLOTHLET_INTERNAL_TEST_MODE = "true";
+
+import { describe, it, expect, afterEach } from "vitest";
+import slothlet from "@cldmv/slothlet";
+import { MODULE_ID_SEPARATOR } from "#handlers/metadata";
+import { TEST_DIRS } from "../../setup/vitest-helper.mjs";
+
+const EAGER_CONFIGS = [
+ { name: "eager/hooks-on", config: { mode: "eager", runtime: "async", hook: { enabled: true } } },
+ { name: "eager/hooks-off", config: { mode: "eager", runtime: "async", hook: { enabled: false } } }
+];
+
+// Every id here is an accepted moduleID that a consumer might use; the colon ones are the regression,
+// the hyphen one is the control that always worked, and the multi-colon one guards the "split on the
+// first colon" assumption specifically.
+const COLON_IDS = ["vine:abc", "plugin:opensearch:v1"];
+const CONTROL_ID = "vine-abc";
+
+describe.each(EAGER_CONFIGS)("colon moduleID round-trips — $name", ({ config }) => {
+ let api;
+
+ afterEach(async () => {
+ if (api?.shutdown) await api.shutdown();
+ api = null;
+ await new Promise((r) => setTimeout(r, 30));
+ });
+
+ it.each(COLON_IDS)("remove(id) removes a directory mount added under moduleID %s", async (moduleID) => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ const id = await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID });
+ expect(id).toBe(moduleID); // add() hands back the exact id
+ expect(api.shopfront).toBeDefined();
+
+ // The exact id add() returned must remove the mount.
+ const removed = await api.slothlet.api.remove(id);
+ expect(removed).toBe(true);
+ expect(api.shopfront).toBeUndefined();
+ });
+
+ it.each(COLON_IDS)("leaves(id) enumerates the owned paths for moduleID %s", async (moduleID) => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID });
+
+ // Resolution is by verbatim id — leaves must not throw API_LEAVES_UNKNOWN_MODULE and must
+ // return this mount's owned callable paths (host tooling call).
+ const paths = await api.slothlet.api.leaves(moduleID, { includePrivate: true });
+ expect(Array.isArray(paths)).toBe(true);
+ expect(paths.length).toBeGreaterThan(0);
+ expect(paths.every((p) => p.startsWith("shopfront"))).toBe(true);
+ });
+
+ it.each(COLON_IDS)("reload(id) rebuilds a mount added under moduleID %s", async (moduleID) => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID });
+ expect(api.shopfront).toBeDefined();
+
+ await expect(api.slothlet.api.reload(moduleID)).resolves.toBeUndefined();
+ expect(api.shopfront).toBeDefined();
+ });
+
+ it("remove(id) removes a synthetic (in-memory) mount added under a colon moduleID", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ const id = await api.slothlet.api.add("synth", () => 1, { moduleID: "vine:xyz" });
+ expect(id).toBe("vine:xyz");
+ expect(typeof api.synth).toBe("function");
+
+ expect(await api.slothlet.api.remove(id)).toBe(true);
+ expect(api.synth).toBeUndefined();
+ });
+
+ it.each(COLON_IDS)("remove(id) still works after reload(id) for moduleID %s", async (moduleID) => {
+ // Guards against reload re-attributing the mount's children under a colon-truncated base id,
+ // which would leave remove() unable to find them afterward.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID });
+ await api.slothlet.api.reload(moduleID);
+ expect(api.shopfront).toBeDefined();
+
+ expect(await api.slothlet.api.remove(moduleID)).toBe(true);
+ expect(api.shopfront).toBeUndefined();
+ });
+
+ it("control: a hyphen moduleID still removes (no regression)", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ const id = await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID: CONTROL_ID });
+ expect(id).toBe(CONTROL_ID);
+ expect(await api.slothlet.api.remove(id)).toBe(true);
+ expect(api.shopfront).toBeUndefined();
+ });
+
+ it("remove('vine:abc') must NOT collide with a registered 'vine' module (no ':'-prefix truncation)", async () => {
+ // Regression: resolving a moduleID must never truncate on ':' — removing an unregistered
+ // "vine:abc" once matched the registered base "vine" and wrongly removed it.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID: "vine" });
+ expect(api.shopfront).toBeDefined();
+
+ expect(await api.slothlet.api.remove("vine:abc")).toBe(false);
+ expect(api.shopfront).toBeDefined(); // the "vine" mount survives
+
+ // And the real id still removes it.
+ expect(await api.slothlet.api.remove("vine")).toBe(true);
+ expect(api.shopfront).toBeUndefined();
+ });
+
+ it("rejects a moduleID containing the reserved internal separator", async () => {
+ // The one token a module id may NOT contain — it is the delimiter slothlet joins the id and
+ // apiPath with in the internal composite key, so allowing it would corrupt that key.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await expect(api.slothlet.api.add("blocked", TEST_DIRS.API_TEST_MIXED, { moduleID: `a${MODULE_ID_SEPARATOR}b` })).rejects.toMatchObject(
+ { code: "MODULE_ID_RESERVED_SEPARATOR" }
+ );
+ expect(api.blocked).toBeUndefined();
+ });
+
+ it("rejects when the auto-generated moduleID would contain the separator (apiPath carries it)", async () => {
+ // No moduleID supplied: the default is derived from the apiPath, so an apiPath segment carrying
+ // the reserved token would smuggle it into the auto-generated id. Refuse it at add() too.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ const badPath = `seg${MODULE_ID_SEPARATOR}ment`;
+ await expect(api.slothlet.api.add(badPath, TEST_DIRS.API_TEST_MIXED)).rejects.toMatchObject({ code: "MODULE_ID_RESERVED_SEPARATOR" });
+ expect(api[badPath]).toBeUndefined();
+ });
+
+ it("rejects an apiPath carrying the reserved separator even when a clean moduleID is supplied", async () => {
+ // The separator is reserved in apiPaths too, not only moduleIDs. An explicit clean moduleID does
+ // not make a separator-bearing apiPath safe: remove()/leaves() resolution splits the argument on
+ // the separator, so a later remove(`segment`) would resolve to a "seg" module and detach the
+ // wrong mount. Refuse the apiPath at add() regardless of whether a moduleID is supplied.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ const badPath = `seg${MODULE_ID_SEPARATOR}ment`;
+ // With a clean explicit moduleID the offending token is the apiPath, not the id, so it surfaces as a
+ // path-validation error rather than MODULE_ID_RESERVED_SEPARATOR (which covers the auto-generated case).
+ await expect(api.slothlet.api.add(badPath, TEST_DIRS.API_TEST_MIXED, { moduleID: "cleanId" })).rejects.toMatchObject({
+ code: "INVALID_CONFIG_API_PATH_INVALID"
+ });
+ expect(api[badPath]).toBeUndefined();
+ });
+});
diff --git a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs
new file mode 100644
index 00000000..ce082f18
--- /dev/null
+++ b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs
@@ -0,0 +1,178 @@
+/**
+ * @Project: @cldmv/slothlet
+ * @Filename: /tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs
+ * @Date: 2026-08-24T00:00:00-08:00 (1756022400)
+ * @Author: Nate Corcoran
+ * @Email:
+ * -----
+ * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com)
+ * @Last modified time: 2026-08-24T00:00:00-08:00 (1756022400)
+ * -----
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
+ */
+
+/**
+ * @fileoverview api.remove() accepts an optional second apiPath to scope removal to one module's node.
+ *
+ * @description
+ * `remove(id)` removes every path a moduleID owns; `remove(apiPath)` removes that path's whole subtree
+ * regardless of which module owns each node. Neither can surgically remove a single module's node at a
+ * shared path. The optional second argument closes that gap: `remove(moduleID, apiPath)` removes ONLY the
+ * node at `apiPath` owned by `moduleID`, leaving other modules — and that same module's other mounts —
+ * untouched. Verified in eager and lazy modes.
+ * @module tests/vitests/suites/api-manager/api-manager-remove-scoped
+ */
+
+process.env.SLOTHLET_INTERNAL_TEST_MODE = "true";
+
+import { describe, it, expect, afterEach } from "vitest";
+import slothlet from "@cldmv/slothlet";
+import { resolveWrapper } from "#handlers/unified-wrapper";
+import { TEST_DIRS } from "../../setup/vitest-helper.mjs";
+
+const CONFIGS = [
+ { name: "eager", config: { mode: "eager", runtime: "async", hook: { enabled: true } } },
+ { name: "lazy", config: { mode: "lazy", runtime: "async", hook: { enabled: true } } }
+];
+
+describe.each(CONFIGS)("remove(moduleID, apiPath) scoped removal — $name", ({ config }) => {
+ let api;
+
+ afterEach(async () => {
+ if (api?.shutdown) await api.shutdown();
+ api = null;
+ });
+
+ it("removes only the named module's node at a shared parent path", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("svc.a", () => "A", { moduleID: "modA" });
+ await api.slothlet.api.add("svc.b", () => "B", { moduleID: "modB" });
+ expect(api.svc.a()).toBe("A");
+ expect(api.svc.b()).toBe("B");
+
+ // Scoped to modA at svc.a — modB's svc.b must survive.
+ expect(await api.slothlet.api.remove("modA", "svc.a")).toBe(true);
+ expect(api.svc?.a).toBeUndefined();
+ expect(api.svc.b()).toBe("B");
+ });
+
+ it("removes only one of a moduleID's several mounts", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("alpha", () => "A", { moduleID: "dup" });
+ await api.slothlet.api.add("beta", () => "B", { moduleID: "dup" });
+
+ expect(await api.slothlet.api.remove("dup", "alpha")).toBe(true);
+ expect(api.alpha).toBeUndefined();
+ expect(api.beta()).toBe("B"); // dup's other mount survives
+ });
+
+ it("returns false when the module does not own that path", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("alpha", () => "A", { moduleID: "dup" });
+
+ expect(await api.slothlet.api.remove("dup", "not.there")).toBe(false);
+ expect(api.alpha()).toBe("A"); // untouched
+ });
+
+ it("returns false when the moduleID is unknown", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("svc.b", () => "B", { moduleID: "modB" });
+
+ expect(await api.slothlet.api.remove("noSuchModule", "svc.b")).toBe(false);
+ expect(api.svc.b()).toBe("B"); // modB's node is not removed by another module's scoped call
+ });
+
+ it("single-arg remove(moduleID) still removes every path the module owns", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("alpha", () => "A", { moduleID: "dup" });
+ await api.slothlet.api.add("beta", () => "B", { moduleID: "dup" });
+
+ expect(await api.slothlet.api.remove("dup")).toBe(true);
+ expect(api.alpha).toBeUndefined();
+ expect(api.beta).toBeUndefined();
+ });
+
+ it("rejects a non-string apiPath argument", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await expect(api.slothlet.api.remove("dup", 123)).rejects.toMatchObject({ code: "INVALID_ARGUMENT" });
+ });
+
+ it("recursively removes the module's own subtree under the scoped container, no orphans", async () => {
+ // The module solely owns the whole subtree at "svc": every one of its nodes is removed, the now
+ // childless+unowned container goes too, and nothing is left orphaned in the ownership registry.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("svc", TEST_DIRS.API_TEST_MIXED, { moduleID: "modA" }); // container + children
+ await api.slothlet.api.add("keep", () => "K", { moduleID: "modB" });
+ // Settle the subtree so every descendant ownership record exists (esp. under lazy).
+ const owned = await api.slothlet.api.leaves("modA", { includePrivate: true });
+ expect(owned.length).toBeGreaterThan(0);
+ const ownership = resolveWrapper(api.keep).slothlet.handlers.ownership;
+ expect(ownership.moduleToPath.get("modA")).toBeDefined();
+
+ expect(await api.slothlet.api.remove("modA", "svc")).toBe(true);
+ expect(api.svc).toBeUndefined();
+ expect(ownership.moduleToPath.get("modA")).toBeUndefined(); // no orphaned ownership
+ expect(api.keep()).toBe("K"); // unrelated module untouched
+ });
+
+ it("under a shared container, removes only this module's nodes and keeps the container for the others", async () => {
+ // modA and modB both live under "shop": remove(modA, "shop") deletes modA's leaf, reverts the
+ // shared "shop" container to modB, and leaves modB's leaf and the container standing. It must NOT
+ // delete the whole "shop" path — that is what remove("shop") (path as the first arg) is for.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST });
+ await api.slothlet.api.add("shop.a", () => "A", { moduleID: "modA" });
+ await api.slothlet.api.add("shop.b", () => "B", { moduleID: "modB" });
+ expect(api.shop.a()).toBe("A");
+ expect(api.shop.b()).toBe("B");
+
+ expect(await api.slothlet.api.remove("modA", "shop")).toBe(true);
+ expect(api.shop).toBeDefined(); // container survives for modB
+ expect(api.shop?.a).toBeUndefined(); // modA's node gone
+ expect(api.shop.b()).toBe("B"); // modB's node untouched
+ });
+
+ it("reverts a shadowed node to its previous owner instead of deleting it", async () => {
+ // modA and modB register the SAME leaf path (modB shadows modA via replace), so that node's
+ // ownership stack is two deep. A scoped remove(modB, "leaf") pops modB off the stack and the
+ // node reverts to modA's value — it is NOT deleted, because modA still owns it underneath.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, allowAddApiOverwrite: true });
+ await api.slothlet.api.add("leaf", () => "A", { moduleID: "modA" });
+ await api.slothlet.api.add("leaf", () => "B", { moduleID: "modB", collisionMode: "replace" });
+ expect(typeof api.leaf).toBe("function"); // both owners registered at "leaf"
+
+ expect(await api.slothlet.api.remove("modB", "leaf")).toBe(true);
+ expect(api.leaf()).toBe("A"); // reverted to modA underneath, not deleted
+ });
+
+ it("a scoped removal survives a reload (replayed as scoped, not a whole-path removal)", async () => {
+ // Directory mounts, each a whole module under a shared "shop" container. reload() replays the
+ // two adds then the scoped remove; the modules keep their ids across the reload, so the scoped
+ // remove resolves and only modA's subtree stays gone — modB's must NOT be over-deleted.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: { mutations: { add: true, remove: true, reload: true } } });
+ await api.slothlet.api.add("shop.a", TEST_DIRS.API_TEST_MIXED, { moduleID: "modA" });
+ await api.slothlet.api.add("shop.b", TEST_DIRS.API_TEST_MIXED, { moduleID: "modB" });
+ expect(await api.slothlet.api.remove("modA", "shop")).toBe(true);
+ expect(api.shop?.a).toBeUndefined();
+ expect(api.shop?.b).toBeDefined();
+
+ await api.slothlet.reload();
+ expect(api.shop?.a).toBeUndefined(); // scoped removal persisted
+ expect(api.shop?.b).toBeDefined(); // modB survived the reload, not over-deleted
+ });
+
+ it("a scoped removal of a SYNTHETIC (in-memory) mount survives a reload", async () => {
+ // Synthetic adds record their original inline value as the replay folderPath, so reload()
+ // re-runs the identical synthetic add and then the scoped remove — same guarantee as a
+ // directory mount: modA's node stays gone, modB's synthetic node survives.
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: { mutations: { add: true, remove: true, reload: true } } });
+ await api.slothlet.api.add("shop.a", () => "A", { moduleID: "modA" });
+ await api.slothlet.api.add("shop.b", () => "B", { moduleID: "modB" });
+ expect(await api.slothlet.api.remove("modA", "shop")).toBe(true);
+ expect(api.shop?.a).toBeUndefined();
+ expect(api.shop.b()).toBe("B");
+
+ await api.slothlet.reload();
+ expect(api.shop?.a).toBeUndefined(); // scoped removal persisted
+ expect(api.shop.b()).toBe("B"); // modB's synthetic node replayed, not over-deleted
+ });
+});
diff --git a/tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs
new file mode 100644
index 00000000..18a3eae0
--- /dev/null
+++ b/tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs
@@ -0,0 +1,86 @@
+/**
+ * @Project: @cldmv/slothlet
+ * @Filename: /tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs
+ * @Date: 2026-08-24T00:00:00-08:00 (1756022400)
+ * @Author: Nate Corcoran
+ * @Email:
+ * -----
+ * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com)
+ * @Last modified time: 2026-08-24T00:00:00-08:00 (1756022400)
+ * -----
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
+ */
+
+/**
+ * @fileoverview A synthetic (in-memory) add survives a full-instance reload().
+ *
+ * @description
+ * A directory mount can be replayed by re-reading its folder, but a synthetic add — an inline
+ * function, an export map, or a `{ exports, ...options }` object — has no file to re-read. The add
+ * record therefore captures the ORIGINAL inline value as its replay folderPath (not the internal
+ * `synthetic:` sentinel, which reload would wrongly resolve as a filesystem path). reload()
+ * then re-runs the identical synthetic add and the mount comes back intact, keeping its moduleID so
+ * it stays removable afterward.
+ *
+ * @module tests/vitests/suites/api-manager/api-manager-synthetic-reload
+ */
+
+process.env.SLOTHLET_INTERNAL_TEST_MODE = "true";
+
+import { describe, it, expect, afterEach } from "vitest";
+import slothlet from "@cldmv/slothlet";
+import { TEST_DIRS } from "../../setup/vitest-helper.mjs";
+
+const CONFIGS = [
+ { name: "eager", config: { mode: "eager", runtime: "async", hook: { enabled: true } } },
+ { name: "lazy", config: { mode: "lazy", runtime: "async", hook: { enabled: true } } }
+];
+
+const mutations = { mutations: { add: true, remove: true, reload: true } };
+
+describe.each(CONFIGS)("synthetic add survives reload() — $name", ({ config }) => {
+ let api;
+
+ afterEach(async () => {
+ if (api?.shutdown) await api.shutdown();
+ api = null;
+ });
+
+ it("re-runs an inline-function add across reload", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: mutations });
+ await api.slothlet.api.add("probe", () => "P", { moduleID: "synthFn" });
+ expect(api.probe()).toBe("P");
+
+ await api.slothlet.reload();
+ expect(api.probe()).toBe("P"); // replayed from the recorded inline function
+ });
+
+ it("re-runs an export-map add across reload", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: mutations });
+ await api.slothlet.api.add("bag", { one: () => 1, two: () => 2 }, { moduleID: "synthObj" });
+ expect(api.bag.one()).toBe(1);
+ expect(api.bag.two()).toBe(2);
+
+ await api.slothlet.reload();
+ expect(api.bag.one()).toBe(1);
+ expect(api.bag.two()).toBe(2);
+ });
+
+ it("re-runs a { exports, ...options } shorthand add across reload", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: mutations });
+ await api.slothlet.api.add("short", { exports: { hi: () => "hi" }, moduleID: "synthShort" });
+ expect(api.short.hi()).toBe("hi");
+
+ await api.slothlet.reload();
+ expect(api.short.hi()).toBe("hi");
+ });
+
+ it("keeps the synthetic mount removable by its moduleID after reload", async () => {
+ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: mutations });
+ await api.slothlet.api.add("probe", () => "P", { moduleID: "synthFn" });
+ await api.slothlet.reload();
+
+ expect(await api.slothlet.api.remove("synthFn")).toBe(true); // id survived the reload
+ expect(api.probe).toBeUndefined();
+ });
+});
diff --git a/tests/vitests/suites/metadata/system-metadata.test.vitest.mjs b/tests/vitests/suites/metadata/system-metadata.test.vitest.mjs
index 2d47c20d..79197f75 100644
--- a/tests/vitests/suites/metadata/system-metadata.test.vitest.mjs
+++ b/tests/vitests/suites/metadata/system-metadata.test.vitest.mjs
@@ -15,7 +15,7 @@
* @fileoverview Tests for system metadata (immutable, auto-generated).
*
* System metadata is automatically set by slothlet and CANNOT be modified:
- * - moduleID: Module identifier with format "prefix_id:apiPath"
+ * - moduleID: Module identifier with format `prefix_id${MODULE_ID_SEPARATOR}apiPath`
* - filePath: Absolute path to source file
* - apiPath: Dotted path in API tree
* - sourceFolder: Directory where module was loaded from
@@ -25,6 +25,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import slothlet from "@cldmv/slothlet";
+import { MODULE_ID_SEPARATOR } from "#handlers/metadata";
import { getMatrixConfigs, TEST_DIRS, materialize } from "../../setup/vitest-helper.mjs";
describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config }) => {
@@ -51,7 +52,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config
expect(meta).toBeDefined();
expect(meta.moduleID).toBeDefined();
expect(typeof meta.moduleID).toBe("string");
- expect(meta.moduleID).toMatch(/^base_[a-z0-9]+:rootMath\/add$/);
+ expect(meta.moduleID).toMatch(new RegExp(`^base_[a-z0-9]+${MODULE_ID_SEPARATOR}rootMath/add$`));
});
it("should have correct filePath for base API", async () => {
@@ -89,7 +90,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config
expect(meta.moduleID).toBeDefined();
expect(typeof meta.moduleID).toBe("string");
- expect(meta.moduleID).toMatch(/^plugins_[a-z0-9]+:plugins\/config\/settings\/getPluginConfig$/);
+ expect(meta.moduleID).toMatch(new RegExp(`^plugins_[a-z0-9]+${MODULE_ID_SEPARATOR}plugins/config/settings/getPluginConfig$`));
});
it("should have sourceFolder matching added dir", async () => {
@@ -212,7 +213,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config
// After explicit materialization, metadata comes from specific function
expect(metaAfter.apiPath).toBe("lazyTest.config.settings.getPluginConfig");
- expect(metaAfter.moduleID).toMatch(/^lazyTest_[a-z0-9]+:lazyTest\/config\/settings\/getPluginConfig$/);
+ expect(metaAfter.moduleID).toMatch(new RegExp(`^lazyTest_[a-z0-9]+${MODULE_ID_SEPARATOR}lazyTest/config/settings/getPluginConfig$`));
expect(metaAfter.filePath).toContain("settings.mjs");
// After materialization, __type should return "function" (the typeof the impl)
@@ -247,7 +248,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config
await materialize(api, "rootMath.add", 1, 2);
const meta = api.rootMath.add.__metadata;
- expect(meta.moduleID).toMatch(/^base_[a-z0-9]+:/);
+ expect(meta.moduleID).toMatch(new RegExp(`^base_[a-z0-9]+${MODULE_ID_SEPARATOR}`));
});
it("should use custom prefix for added API", async () => {
@@ -255,14 +256,14 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config
await materialize(api, "custom.config.settings.getPluginConfig");
const meta = api.custom.config.settings.getPluginConfig.__metadata;
- expect(meta.moduleID).toMatch(/^custom_[a-z0-9]+:/);
+ expect(meta.moduleID).toMatch(new RegExp(`^custom_[a-z0-9]+${MODULE_ID_SEPARATOR}`));
});
- it("should include full apiPath after colon", async () => {
+ it("should include full apiPath after the separator", async () => {
await materialize(api, "rootMath.add", 1, 2);
const meta = api.rootMath.add.__metadata;
- const [prefix, path] = meta.moduleID.split(":");
+ const [prefix, path] = meta.moduleID.split(MODULE_ID_SEPARATOR);
expect(prefix).toMatch(/^base_[a-z0-9]+$/);
expect(path).toBe("rootMath/add");
});
@@ -278,7 +279,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config
// Should retrieve system metadata
expect(metadata).toBeDefined();
expect(metadata.moduleID).toBeDefined();
- expect(metadata.moduleID).toMatch(/^base_[a-z0-9]+:rootMath\/add$/);
+ expect(metadata.moduleID).toMatch(new RegExp(`^base_[a-z0-9]+${MODULE_ID_SEPARATOR}rootMath/add$`));
expect(metadata.filePath).toContain("root-math.mjs");
expect(metadata.apiPath).toBe("rootMath.add");
expect(metadata.sourceFolder).toContain("api_test");
diff --git a/tests/vitests/suites/metadata/user-metadata.test.vitest.mjs b/tests/vitests/suites/metadata/user-metadata.test.vitest.mjs
index 95e38ecd..8f9d4900 100644
--- a/tests/vitests/suites/metadata/user-metadata.test.vitest.mjs
+++ b/tests/vitests/suites/metadata/user-metadata.test.vitest.mjs
@@ -25,6 +25,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import slothlet from "@cldmv/slothlet";
+import { MODULE_ID_SEPARATOR } from "#handlers/metadata";
import { getMatrixConfigs, TEST_DIRS, materialize } from "../../setup/vitest-helper.mjs";
describe.each(getMatrixConfigs())("User Metadata > Config: '$name'", ({ config }) => {
@@ -223,7 +224,7 @@ describe.each(getMatrixConfigs())("User Metadata > Config: '$name'", ({ config }
const meta = api.separate.config.settings.getPluginConfig.__metadata;
// System metadata should be correct, not overridden by user
- expect(meta.moduleID).toMatch(/^separate_[a-z0-9]+:/);
+ expect(meta.moduleID).toMatch(new RegExp(`^separate_[a-z0-9]+${MODULE_ID_SEPARATOR}`));
expect(meta.filePath).toContain("settings.mjs");
expect(meta.apiPath).toBe("separate.config.settings.getPluginConfig");
});