# Slothlet v3.0.0 Changelog **Release Status**: Pre-release (development branch `refactor/unified-wrapper-poc`) --- ## Overview Version 3.0.0 is a major release that rebuilds the core of Slothlet from the ground up. The central change is the **Unified Wrapper architecture** - a single, consistent proxy layer that replaces the multiple separate wrapper implementations from v2. Built on top of this are an enhanced hook system (first introduced in v2.7, redesigned in v3), a metadata/ownership system, hot reload, lifecycle events, improved context isolation, sanitization fixes, and a full i18n layer for all error and debug messages. While most behavior is backward-compatible, there are targeted breaking changes in configuration naming, the sanitization helper export, and cross-instance context semantics. --- ## 🚨 Breaking Changes ### Configuration: Collision System Redesigned The v2 boolean flags `allowInitialOverwrite` and `allowAddApiOverwrite` have been removed in favor of a typed collision mode system. ```js // v2 - removed { allowInitialOverwrite: true, allowAddApiOverwrite: false } // v3 - use collision modes { api: { collision: { initial: "merge", // During initial load() api: "skip" // During api.slothlet.api.add() } } } // Or as a string shorthand (applies to both contexts) { api: { collision: "merge" } } ``` See [Collision Modes](#collision-modes) for all valid values. --- ### Configuration: Mutation Controls Redesigned The v2 `allowMutation: false` flag has been replaced with a granular per-operation object. ```js // v2 - removed { allowMutation: false } // v3 - granular control { api: { mutations: { add: false, remove: false, reload: false } } } ``` Each operation (`add`, `remove`, `reload`) can be independently enabled or disabled. All three default to `true`. --- ### Sanitization: Helper Export Renamed The low-level sanitization function exported from `@cldmv/slothlet/helpers/sanitize` has been renamed. ```js // v2 import { sanitizePathName } from "@cldmv/slothlet/helpers/sanitize"; // v3 import { sanitizePropertyName } from "@cldmv/slothlet/helpers/sanitize"; ``` The `api.slothlet.sanitize()` runtime method is the preferred alternative for most use cases (see [Sanitization Improvements](#sanitization-improvements)). --- ### Context: Cross-Instance Behavior Changed `.run()` and `.scope()` now use a **child-instance model** instead of parent-chain traversal. Code that relied on one Slothlet instance's context being visible inside a `.run()` scope from a different instance will no longer work. Each instance's context is now fully isolated by instance ID. ```js // v2 - context leaked across instances via parent chain await api1.slothlet.context.run({ user: "alice" }, async () => { await api2.slothlet.context.run({ requestId: "123" }, async () => { const ctx = await api1.slothlet.context.get(); // OLD: Returned { user: "alice" } - parent chain resolved cross-instance }); }); // v3 - each instance is fully isolated // ctx above would be the api1 base context, not influenced by api2's run() ``` --- ## ✨ New Features ### Unified Wrapper Architecture The core proxy layer has been completely rebuilt as a single `UnifiedWrapper` class (`src/lib/handlers/unified-wrapper.mjs`) that handles all modes (eager, lazy) and all operations (get, set, apply, construct, ownKeys, defineProperty, deleteProperty, getOwnPropertyDescriptor, has). Key improvements: - Single consistent proxy surface for all API wrappers - Proper `util.inspect.custom` integration - `console.log(api.math)` now shows the actual contents, not `{}` - `___setImpl()` / `___resetLazy()` internal operations for controlled implementation updates - Background materialization via `materializeOnCreate` flag - ADOPT system for safely merging child implementations into wrapper state on materialization - Waiting proxy system for deferred property access chains before materialization completes --- ### Hook System > **Note:** The hook system was introduced in v2.7.0. In v3 it has been redesigned with a new config key, a new access path, and a three-phase subset ordering feature. See [v2.7 changelog](v2/v2.7.md) for the original feature description. **v2 β†’ v3 changes:** | | v2.7 | v3.0 | | --------------- | ----------- | ------------------- | | Config key | `hooks` | `hook` | | Access path | `api.hooks` | `api.slothlet.hook` | | Subset ordering | - | βœ… | #### Hook Types | Type | Runs | Can modify | | -------- | ---------------------------------- | -------------------------------------------------------------------------- | | `before` | Before the function | Arguments (via `modifiedArgs`), can short-circuit with custom return value | | `after` | After successful completion | Return value (via `modifiedResult`) | | `error` | On thrown error | Error object | | `always` | After any outcome (like `finally`) | - | #### Configuration ```js const api = await slothlet({ dir: "./api", hook: true // enable all hooks (pattern "**") }); // Or with a glob pattern const api = await slothlet({ dir: "./api", hook: "math.**" // only hooks under math.* }); // Or fully explicit const api = await slothlet({ dir: "./api", hook: { enabled: true, pattern: "**", suppressErrors: false // hook errors propagate by default } }); ``` #### Registration ```js api.slothlet.hook.on("math.add", "before", (ctx) => { console.log("before add:", ctx.args); }); api.slothlet.hook.on("math.add", "after", (ctx) => { console.log("result:", ctx.result); }); api.slothlet.hook.on("**", "error", (ctx) => { console.error("API error:", ctx.error); }); ``` #### Hook Subsets **New in v3.** Each hook type (`before`, `after`, `always`, `error`) is divided into three ordered execution phases called subsets: `before β†’ primary β†’ after`. Within each subset, hooks sort by priority (highest first). Use subsets to express ordering guarantees without relying on raw priority values: ```js // Auth must run before any other before-hooks api.slothlet.hook.on("**", "before", authHandler, { id: "auth", subset: "before", // runs first within the before phase priority: 2000 }); // Business validation - the default api.slothlet.hook.on("**", "before", validateHandler, { id: "validate", subset: "primary" // default if omitted }); // Audit log always runs last api.slothlet.hook.on("**", "before", auditHandler, { id: "audit", subset: "after" // runs last within the before phase }); // Execution order for 'before' hooks: // [subset=before, ↓priority] β†’ [subset=primary, ↓priority] β†’ [subset=after, ↓priority] // Then the API function runs, then the 'after' hooks follow the same pattern. ``` Error suppression is per-registration and can be configured globally with `suppressErrors: true`. #### Async Handling Hooks execute synchronously. For async API functions, after/error/always hooks are attached to the returned Promise via `.then()` - they do **not** block the event loop: ```js // Sync function: hooks run inline, synchronously api.math.add(2, 3); // before β†’ fn β†’ after β†’ always (all sync) // Async function: before is sync, rest attach to Promise chain await api.db.query(); // before (sync) β†’ Promise β†’ .then(after + always) ``` #### Pattern Matching - Glob patterns (`**`, `*`, `?`) - Brace expansion: `{math,string}.**` - Negation: `!internal.*` - `apiDepth` config for limiting directory traversal --- ### Hot Reload System **Completed:** January 30, 2026 - 56 tests passing #### Full Instance Reload ```js await api.slothlet.reload(); ``` Reloads all modules from disk while preserving the user's `api` reference. Operation history (`api.add()` / `api.remove()` calls made at runtime) is replayed chronologically so dynamic API modifications survive the reload. > **Eager mode only.** Reference preservation applies to eager-mode instances. Lazy-mode instances intentionally restore to an **unmaterialized** state on reload - existing lazy wrappers will re-materialize on next property access. This is by design: lazy mode's deferred loading semantics require a clean slate so stale module closures are not retained. **How it works:** 1. The `api` variable is backed by a stable proxy (`boundApi`) whose internal `_currentApi` target is swapped on reload 2. ESM module cache is busted via a temporary instance ID; CJS cache is cleared via `require.cache` cleanup 3. All `add()`/`remove()` operations from `operationHistory` are replayed in order after the fresh load #### Partial Reload ```js await api.slothlet.api.reload("plugins"); // or by module ID await api.slothlet.api.reload("plugins-v1"); ``` Reloads only the modules registered under a specific API path or module ID, without rebuilding the entire instance. #### Reload with Metadata Update ```js // Atomically reload + update path metadata in one step await api.slothlet.api.reload("plugins", { metadata: { version: "2.0.0", updated: true } }); ``` --- ### `backgroundMaterialize` Config Option ```js const api = await slothlet({ dir: "./api", mode: "lazy", backgroundMaterialize: true // default: false }); ``` When `true`, lazy-mode proxies begin materializing immediately during proxy creation (fire-and-forget async) rather than waiting for first property access. Trade-offs: | | `backgroundMaterialize: false` | `backgroundMaterialize: true` | | ------------------ | ------------------------------ | ----------------------------- | | Startup time | Fast | Slower (modules pre-load) | | First-call latency | Higher (loads + executes) | Lower (already loaded) | | `__type` accuracy | Delayed | Immediate | | Memory (startup) | Low | Higher | When all wrappers have finished materializing, the `materialized:complete` lifecycle event fires. Enable the tracking option to receive it: ```js const api = await slothlet({ dir: "./api", mode: "lazy", backgroundMaterialize: true, tracking: { materialization: true } }); api.slothlet.lifecycle.on("materialized:complete", ({ total, timestamp }) => { console.log(`All ${total} modules ready at ${timestamp}`); }); ``` --- ### `__type` Property and `api.slothlet.types` ```js import slothlet from "@cldmv/slothlet"; const api = await slothlet({ dir: "./api", mode: "lazy" }); // typeof always returns "function" (proxy target) typeof api.math; // "function" // __type returns actual implementation type api.math.__type; // api.slothlet.types.UNMATERIALIZED (before first access) // api.slothlet.types.IN_FLIGHT (loading) // "object" (after materialization) // "function" (if it exports a function) ``` `api.slothlet.types` exposes the state symbols: ```js const { UNMATERIALIZED, IN_FLIGHT } = api.slothlet.types; if (api.logger.__type === IN_FLIGHT) { console.log("Logger still loading…"); } ``` `TYPE_STATES` is also exported as a named export from the package for use before initialization: ```js import slothlet, { TYPE_STATES } from "@cldmv/slothlet"; ``` --- ### Metadata System **Version:** 3.0.0+ A dual-storage metadata architecture that combines immutable system metadata with flexible user metadata. #### System Metadata (automatic) Populated automatically via the lifecycle system. Read-only. Deeply frozen. ```js api.math.add.__metadata; // { // filePath: "/absolute/path/to/math.mjs", // sourceFolder: "/absolute/path/to", // apiPath: "math.add", // moduleID: "module-id:math", // taggedAt: 1706400000000, // // + any user metadata layered on top // } ``` #### User Metadata ```js // Set per-function metadata (requires function reference) api.slothlet.metadata.set(api.math.add, "category", "math"); // Set for all functions under a path (no reference needed) api.slothlet.metadata.setFor("math", "category", "math"); api.slothlet.metadata.setFor("math", { category: "math", version: "2.0.0" }); // Remove path-level metadata api.slothlet.metadata.removeFor("math", "category"); api.slothlet.metadata.removeFor("math"); // remove all keys for path // Set global metadata (applies to every function) api.slothlet.metadata.setGlobal("appVersion", "3.0.0"); ``` **Priority** (lowest β†’ highest): global β†’ path (`setFor`) β†’ function (`set`) β†’ system (always wins for `filePath`, `apiPath`, `moduleID`) #### Metadata Survives Reload `set()` and `setGlobal()` values are now preserved across `api.slothlet.reload()`. The metadata manager exports/imports its user state around the reload. --- ### Ownership & History System **Version:** 3.0.0+ Each API path maintains an **ownership stack** of every module that has registered an implementation at that path. This powers: - **Automatic rollback**: when `api.slothlet.api.remove()` is called, the previous owner's implementation is automatically restored - **Conflict detection**: `OWNERSHIP_CONFLICT` error in `collision: "error"` mode - **Hot reload continuity**: ownership history is preserved and updated when implementations change The ownership system registers automatically via the `impl:created` / `impl:changed` lifecycle events - no user code required. --- ### Collision Modes **Version:** 3.0.0+ Six modes available for both the `initial` (during `load()`) and `api` (during `api.add()`) contexts: | Mode | Behavior on conflict | Non-conflicting keys | | ------------------- | ------------------------------------------- | -------------------- | | `merge` _(default)_ | First loaded wins | Both sources added | | `merge-replace` | Second loaded wins | Both sources added | | `replace` | Second completely replaces first | Only second source | | `skip` | First is kept, second silently discarded | Only first source | | `warn` | Same as `merge`, but logs a warning | Both sources added | | `error` | Throws `SlothletError` (OWNERSHIP_CONFLICT) | – | Mode strings are **case-insensitive**. Invalid values fall back to `"merge"`. --- ### Lifecycle Events API **Version:** 3.0.0+ ```js // Subscribe api.slothlet.lifecycle.on("impl:changed", (data) => { console.log(`Reloaded: ${data.apiPath}`); }); // Unsubscribe const onMaterialized = (data) => { console.log(`All ${data.total} modules ready`); }; api.slothlet.lifecycle.on("materialized:complete", onMaterialized); // Later: api.slothlet.lifecycle.off("materialized:complete", onMaterialized); ``` | Event | When emitted | Key data | | ----------------------- | ------------------------------------------- | ------------------------------------------- | | `impl:created` | New module loaded (initial or `api.add()`) | `apiPath`, `moduleID`, `filePath`, `impl` | | `impl:changed` | Implementation replaced (reload, collision) | `apiPath`, `moduleID`, `oldImpl`, `newImpl` | | `impl:removed` | Module removed via `api.remove()` | `apiPath`, `moduleID` | | `materialized:complete` | All lazy modules materialized | `total`, `timestamp` | `materialized:complete` requires `tracking: { materialization: true }` in config. --- ### Per-Request Context Isolation **Version:** 3.0.0+ (replaced parent-chain model) `.run()` and `.scope()` now create **child instances** (pattern: `${baseID}__run_${timestamp}_${random}`) that carry an isolated context. Two isolation levels are supported: ```js // Partial isolation (default): context is isolated, self (API state) is shared await api.slothlet.context.run({ requestId: "abc" }, async () => { // Mutations to API state here persist outside this .run() }); // Full isolation: context AND self are deep-cloned await api.slothlet.context.scope({ context: { requestId: "abc" }, isolation: "full", fn: async () => { // Mutations to API state here do NOT persist outside this scope } }); ``` Instance-level default: ```js const api = await slothlet({ dir: "./api", scope: { isolation: "full", // default for all .run()/.scope() calls merge: "shallow" // context merge strategy: "shallow" | "deep" } }); ``` `.run()` now delegates internally to `.scope()` - they share the same implementation. --- ### Sanitization Improvements **Version:** 3.0.0+ #### New: `api.slothlet.sanitize(str)` Runtime convenience method that sanitizes using the same config the instance was initialized with: ```js const api = await slothlet({ dir: "./api", sanitize: { rules: { upper: ["http"] } } }); api.slothlet.sanitize("get-http-status"); // β†’ "getHTTPStatus" api.slothlet.sanitize("my-module.mjs"); // β†’ "myModule" ``` #### Bug Fix: `lower` Rule Overwrote by CamelCase Phase Pattern-based `lower` rules now correctly survive the camelCase transformation phase: ```js // v2 (incorrect) sanitizePropertyName("get-API-status", { rules: { lower: ["*-api-*"] } }); // β†’ "getApiStatus" ❌ // v3 (correct) sanitizePropertyName("get-API-status", { rules: { lower: ["*-api-*"] } }); // β†’ "getapiStatus" βœ… ``` #### Bug Fix: `leave` Rule is Now Strictly Case-Sensitive ```js // v2 - incorrectly matched despite case difference sanitizePropertyName("auto-ip", { rules: { leave: ["IP"] } }); // β†’ "autoip" ❌ // v3 - correct: no match, normal camelCase applied sanitizePropertyName("auto-ip", { rules: { leave: ["IP"] } }); // β†’ "autoIp" βœ… ``` Use `leaveInsensitive` when case-insensitive matching is required. #### New: Two-Level Segmentation Underscore sub-segments within hyphenated primary segments are processed correctly: ```js sanitizePropertyName("Mixed_APPS_some-thing", { preserveAllUpper: true }); // β†’ "mixed_APPS_someThing" ``` #### New: Underscore Glob Patterns ```js sanitizePropertyName("api_helper", { rules: { upper: ["api_*"] } }); // β†’ "API_helper" ``` --- ### i18n Error & Debug System **Version:** 3.0.0+ All Slothlet errors, warnings, and debug messages are now fully localized through an i18n layer. - `SlothletError` - translated error code + hint system - `SlothletWarning` - translated warning messages - `SlothletDebug` - categorized debug logging with `DEBUG_MODE_*` keys ```js // All debug calls use translation keys - no hardcoded strings this.debug("api", { key: "DEBUG_MODE_ASSIGN_TO_API", propKey, valueId, typeOf }); // Errors include auto-resolved hints where applicable throw new SlothletError("INVALID_API_PATH", { validationError: true, path }); ``` The i18n system is exposed at runtime: ```js api.slothlet.i18n; // i18n manager (load additional languages, change language) ``` **Bundled languages:** | Code | Language | | ------- | --------------------------------- | | `en-us` | English (United States) - default | | `en-gb` | English (United Kingdom) | | `de-de` | German | | `es-mx` | Spanish (Mexico) | | `fr-fr` | French | | `hi-in` | Hindi | | `ja-jp` | Japanese | | `ko-kr` | Korean | | `pt-br` | Portuguese (Brazil) | | `ru-ru` | Russian | | `zh-cn` | Chinese (Simplified) | --- ## πŸ”§ API Changes ### New Config Options | Option | Type | Default | Description | | ----------------------- | ----------------------------- | ----------- | ------------------------------------------------------------------------ | | `api.collision` | `string \| { initial, api }` | `"merge"` | Collision mode (replaces `allowInitialOverwrite`/`allowAddApiOverwrite`) | | `api.mutations` | `{ add, remove, reload }` | all `true` | Granular mutation controls (replaces `allowMutation`) | | `backgroundMaterialize` | `boolean` | `false` | Pre-load lazy modules during init | | `hook` | `boolean \| string \| object` | `false` | Enable hook system | | `scope.isolation` | `"partial" \| "full"` | `"partial"` | Context isolation level for `.run()`/`.scope()` | | `scope.merge` | `"shallow" \| "deep"` | `"shallow"` | Context merge strategy | | `debug` | `boolean \| object` | `false` | Debug categories (boolean enables all) | | `silent` | `boolean` | `false` | Suppress all console output | | `apiDepth` | `number` | `Infinity` | Max API nesting depth | ### New `api.slothlet.*` Methods | Method | Description | | ------------------------------------------------ | ------------------------------------------------ | | `api.slothlet.api.add(path, dir, meta?, opts?)` | Dynamically add modules at runtime | | `api.slothlet.api.remove(pathOrModuleId)` | Remove modules by path or module ID | | `api.slothlet.api.reload(pathOrModuleId, opts?)` | Partial reload with optional `{ metadata }` | | `api.slothlet.reload()` | Full instance reload with reference preservation | | `api.slothlet.sanitize(str)` | Sanitize using instance's config | | `api.slothlet.shutdown()` | Shut down instance and clean up resources | | `api.slothlet.scope(fn, ctx?)` | Run function with isolated context | | `api.slothlet.run(fn, ctx?)` | Alias for `.scope()` | ### New `api.slothlet.metadata.*` Methods | Method | Description | | --------------------------------------- | -------------------------------------------- | | `metadata.set(fn, key, val)` | Set per-function metadata | | `metadata.get(fn)` | Get combined metadata for a function | | `metadata.setFor(path, keyOrObj, val?)` | Set metadata for all functions under a path | | `metadata.removeFor(path, key?)` | Remove path-level metadata | | `metadata.setGlobal(key, val)` | Set global metadata for all functions | | `metadata.getSystem(fn)` | Get system-only metadata (no user overrides) | ### New `api.slothlet.lifecycle.*` Methods | Method | Description | | ------------------------------- | ---------------------------- | | `lifecycle.on(event, handler)` | Subscribe to lifecycle event | | `lifecycle.off(event, handler)` | Unsubscribe | ### New Properties and Exports | Symbol/Property | Description | | -------------------- | ----------------------------------------------------------------- | | `api.slothlet.types` | `{ UNMATERIALIZED, IN_FLIGHT }` symbols for `__type` checks | | `api.slothlet.diag` | Diagnostic API (`describe`, `inspect`, `owner`, `caches`, `hook`) | | `TYPE_STATES` | Named export from package (same symbols, pre-init use) | | `wrapper.__type` | Actual implementation type for lazy wrappers | | `wrapper.__metadata` | Combined system + user metadata for any API function | --- ## Test Coverage | Suite | Tests | Status | | --------------------------- | ------ | -------------- | | Hooks (14 files) | 557 | βœ… All passing | | Hot reload (full + partial) | 56 | βœ… All passing | | Metadata (external API) | 248 | βœ… All passing | | Metadata (reload) | 168 | βœ… All passing | | Sanitization | 104 | βœ… All passing | | API mutation controls | 176 | βœ… All passing | | Per-request context | 173 | βœ… All passing | | Total (all suites) | 5,700+ | βœ… All passing | --- ## πŸ”€ Migration Guide ### From v2 to v3 #### 1. Update collision config ```js // Before { allowInitialOverwrite: true, allowAddApiOverwrite: false } // After { api: { collision: { initial: "merge", api: "skip" } } } ``` #### 2. Update mutation config ```js // Before { allowMutation: false } // After { api: { mutations: { add: false, remove: false, reload: false } } } ``` #### 3. Update sanitize import (if used) ```js // Before import { sanitizePathName } from "@cldmv/slothlet/helpers/sanitize"; // After import { sanitizePropertyName } from "@cldmv/slothlet/helpers/sanitize"; // or use the runtime method: api.slothlet.sanitize("my-string"); ``` #### 4. Review cross-instance context code If you have code that calls `api1.slothlet.context.run(...)` and then expects `api1`'s context to be readable from inside `api2.slothlet.context.run(...)` (cross-instance parent chain), the behavior has changed. Each instance's context is now fully isolated. #### 5. Update `TYPE_STATES` usage (if using lazy mode type checks) ```js // Before (if you had a workaround) typeof api.math === "function" && api.math._impl && typeof api.math._impl; // After import { TYPE_STATES } from "@cldmv/slothlet"; api.math.__type === TYPE_STATES.UNMATERIALIZED; // not loaded api.math.__type === TYPE_STATES.IN_FLIGHT; // loading api.math.__type === "object"; // loaded, is object api.math.__type === "function"; // loaded, is function ``` --- ## πŸ“ Related Documentation - [HOOKS.md](../HOOKS.md) - Full hook system reference - [METADATA.md](../METADATA.md) - Metadata system reference - [RELOAD.md](../RELOAD.md) - Hot reload system reference - [CONFIGURATION.md](../CONFIGURATION.md) - All config options - [CONTEXT-PROPAGATION.md](../CONTEXT-PROPAGATION.md) - Per-request context - [SANITIZATION.md](../SANITIZATION.md) - Sanitization rules - [LIFECYCLE.md](../LIFECYCLE.md) - Lifecycle events - [I18N.md](../I18N.md) - i18n system - [API-RULES.md](../API-RULES.md) - Module loading and flattening rules - [docs/v3/changes/](../v3/changes/) - Detailed per-feature implementation notes