Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
55cef13
fix: let a moduleID containing a colon round-trip through add/remove/…
Shinrai Aug 24, 2026
5698580
fix: don't ':'-truncate a moduleID when resolving remove() fallback
Shinrai Aug 24, 2026
1dc1060
feat: scope api.remove() to one module's node via an optional apiPath
Shinrai Aug 24, 2026
68a1df0
fix: resolve a composite moduleID:apiPath in remove() without collidi…
Shinrai Aug 24, 2026
fb3ac95
refactor: use a reserved multi-char separator for the moduleID:apiPat…
Shinrai Aug 24, 2026
9f7207c
docs: correct removeApiComponent JSDoc (boolean return, options, sepa…
Shinrai Aug 24, 2026
4ba4570
fix: refuse an auto-generated moduleID that would contain the reserve…
Shinrai Aug 24, 2026
2b717be
docs: refresh stale ':'-separator comments in unified-wrapper baseMod…
Shinrai Aug 24, 2026
1b7354a
fix: prefix-remove the whole subtree in scoped remove(moduleID, apiPath)
Shinrai Aug 25, 2026
94561b2
fix: make scoped remove(moduleID, apiPath) detach only this module, n…
Shinrai Aug 25, 2026
5ccd752
fix: don't record scoped remove in operationHistory (would replay as …
Shinrai Aug 25, 2026
a50c35a
feat: replay scoped remove(moduleID, apiPath) across reload
Shinrai Aug 25, 2026
128704a
fix(api-manager): replay synthetic adds across reload; cover scoped-r…
Shinrai Aug 25, 2026
19f86a9
refactor(api-manager): replace dishonest "never in tests" v8-ignores …
Shinrai Aug 25, 2026
4e307cd
fix(api-manager): reject the reserved separator in an apiPath, not on…
Shinrai Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/generated/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,17 +581,18 @@ await api.slothlet.api.reload();

<a id="typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-remove"></a>

#### api.slothlet.api.remove(pathOrModuleId) ⇒ <code>Promise.&lt;void&gt;</code>
#### api.slothlet.api.remove(pathOrModuleId, apiPath?) ⇒ <code>Promise.&lt;boolean&gt;</code>

Unmount an API module at runtime.
Unmount an API module at runtime. <code>remove(id)</code> removes every path the module owns; <code>remove(apiPath)</code> removes that path's whole subtree; the two-argument <code>remove(moduleID, apiPath)</code> 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 [<code>SlothletAPI</code>](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI)

| Param | Type | Description |
| --- | --- | --- |
| pathOrModuleId | <code>string</code> | |
| apiPath? | <code>string</code> | |

**Returns**: <code>Promise.&lt;void&gt;</code>
**Returns**: <code>Promise.&lt;boolean&gt;</code>

**Example**
```javascript
Expand Down
29 changes: 24 additions & 5 deletions src/lib/builders/api_builder.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -803,16 +803,26 @@ export class ApiBuilder extends ComponentBase {

/**
* @param {string} pathOrModuleId - API path or module ID to remove.
* @returns {Promise<void>}
* @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<boolean>} 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", {
Expand All @@ -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 });
},

/**
Expand Down
231 changes: 192 additions & 39 deletions src/lib/handlers/api-manager.mjs

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions src/lib/handlers/metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<path>` 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
Expand Down Expand Up @@ -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
Expand All @@ -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()
});

Expand Down
17 changes: 9 additions & 8 deletions src/lib/handlers/unified-wrapper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:<path>` id) without being truncated on recovery (#303).
childModuleId = parentMetadata.baseModuleID;
}

const childSourceFolder = childExistingMetadata?.sourceFolder || parentMetadata?.sourceFolder || null;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/i18n/languages/de-de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/i18n/languages/en-gb.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/i18n/languages/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/i18n/languages/es-es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/i18n/languages/es-mx.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
Loading
Loading