ProjectsmahiHelpers

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

Helpers

@mahiframework/core ships the utility layer everything else is built on: string and array manipulation, an ordered-map Collection, locale-aware number formatting, type-checked dot-notation access into nested data, and a handful of Laravel's global helpers. Three small standalone packages sit alongside it, @mahiframework/pipeline, @mahiframework/process, @mahiframework/tui, each usable without the framework.

import { Str, Arr, Collection, Num, data_get, collect } from "@mahiframework/core";

Str.slug("Héllo, World!");                      // "hello-world"
Arr.wrap(maybeArray);                            // always an array
collect(users).groupBy("role").keys().all();     // ["admin", "member"]
Num.fileSize(2_400_000);                         // "2 MB"
data_get(config, "database.connections.sqlite"); // typed, compile-checked

Nothing here touches the container, the config repository, or the application. These are pure functions and value objects, import them anywhere, including inside a config/*.ts file that runs before bootstrap().

Str

A frozen object of pure string functions. No Stringable wrapper, no fluent chain, Laravel's Str::of() exists because PHP has no method chaining on scalars, which isn't a problem TypeScript has.

Case conversion

All four case converters go through one private splitWords():

function splitWords(value: string): string[] {
  return value
    .replace(/([a-z0-9])([A-Z])/g, "$1 $2")   // camelCase → camel Case
    .split(/[\s\-_]+/)                         // split on space, dash, underscore
    .filter((word) => word.length > 0)
    .map((word) => word.toLowerCase());
}

That's why the conversions round-trip. Every converter first decomposes its input into the same lowercase word list, then reassembles it, so the input spelling is irrelevant:

Str.camel("foo-bar");     // "fooBar"
Str.camel("foo_bar_baz"); // "fooBarBaz"
Str.camel("FooBar");      // "fooBar"

Str.snake("fooBar");      // "foo_bar"
Str.snake("foo-bar");     // "foo_bar"

Str.kebab("fooBar");      // "foo-bar"
Str.kebab("foo_bar");     // "foo-bar"

Str.studly("foo-bar");    // "FooBar"
Str.studly("foo_bar");    // "FooBar"

The word-boundary regex only fires on a lowercase-or-digit followed by an uppercase. So "HTTPResponse" splits as one word "httpresponse", not "http response", consecutive capitals are not a boundary. If you need acronym-aware splitting, do it yourself before calling.

MethodSignatureResult
camel(value)fooBarBaz
snake(value)foo_bar_baz
kebab(value)foo-bar-baz
studly(value)FooBarBaz

Str.kebab() is what the make:* generators use to derive filenames. make:job SendWelcomeEmail writes send-welcome-email.job.ts. See Console.

slug

slug(value: string, separator = "-"): string

Unicode-normalises to NFKD, strips combining marks, lowercases, then replaces every run of non-[a-z0-9] with separator and trims separators off both ends.

Str.slug("  Héllo, World!  ");   // "hello-world"
Str.slug("Hello World", "_");    // "hello_world"

This is ASCII-only by construction. The [^a-z0-9]+ replacement means a Cyrillic or CJK title slugs to the empty string. That's the honest outcome, transliterating scripts the framework has no table for would produce confidently wrong output. If you need non-Latin slugs, transliterate before calling, or key on an ID instead.

Truncation

limit(value: string, length: number, suffix = "..."): string
words(value: string, words = 100, end = "..."): string

limit() counts characters; words() counts whitespace-separated runs. Both return the input untouched when it's already short enough, and both trimEnd() before appending the suffix.

Str.limit("hi", 10);                  // "hi"
Str.limit("hello world", 5);          // "hello..."
Str.limit("hello world", 5, "…");     // "hello…"

Str.words("one two three four", 2);   // "one two..."
Str.words("one two", 5);              // "one two"

Note limit()'s length budget excludes the suffix. limit(s, 5) can return an 8-character string.

Predicates

contains(haystack: string, needles: string | readonly string[]): boolean
startsWith(haystack: string, needles: string | readonly string[]): boolean
endsWith(haystack: string, needles: string | readonly string[]): boolean

All three accept one needle or several, and are some(), not every(), any match wins.

Str.contains("hello world", "world");             // true
Str.contains("hello world", ["foo", "hello"]);    // true
Str.contains("hello world", "xyz");               // false
Str.contains("hello", "");                        // false — empty needles never match

The empty-needle rule (needle !== "") matters: without it every one of these would return true for "", since "anything".includes("") is true. An empty needle usually means a variable that didn't get set.

isUuid(value: string): boolean   // RFC 4122 v1–v8, any case
isUlid(value: string): boolean   // 26 Crockford-base32 chars, first char 0–7
isJson(value: string): boolean   // JSON.parse succeeds; "" is false

isJson("") is false. An empty string is short-circuited before JSON.parse ever runs, because JSON.parse("") throws anyway and returning false for whitespace is what callers mean.

isUlid's leading [0-7] bound is deliberate: a ULID's first 10 characters encode a 48-bit millisecond timestamp, which cannot exceed 7ZZZZZZZZZ. A string starting with 8Z is not a representable ULID.

Extraction

after(subject, search)       // everything after the FIRST occurrence
afterLast(subject, search)   // everything after the LAST occurrence
before(subject, search)      // everything before the FIRST occurrence
beforeLast(subject, search)  // everything before the LAST occurrence
between(subject, from, to)   // beforeLast(after(subject, from), to)
substr(value, start, length?)

All four after/before variants return the whole subject unchanged when search is "" or isn't found. That's a deliberate no-op rather than an empty string: a missed search usually means the input wasn't the shape you expected, and silently returning "" hides it.

Str.after("app/http/controllers/foo.ts", "/");       // "http/controllers/foo.ts"
Str.afterLast("app/http/controllers/foo.ts", "/");   // "foo.ts"
Str.before("app/http/controllers/foo.ts", "/");      // "app"
Str.beforeLast("app/http/controllers/foo.ts", "/");  // "app/http/controllers"

between() is first from, last to, matching Laravel's Str::between. It is greedy on the right:

Str.between("[a] [b]", "[", "]");   // "a] [b"   — not "a"
Str.between("abc", "x", "y");       // "abc"     — neither found

substr() is slice-with-a-length, not JS's deprecated String.prototype.substr. A negative start counts from the end:

Str.substr("hello", 1, 3);   // "ell"
Str.substr("hello", -2);     // "lo"

Case and formatting

lower(value)     // toLowerCase()
upper(value)     // toUpperCase()
title(value)     // Title Case Every Word
ucfirst(value)   // First character only

title() lowercases the whole string first, then uppercases every letter that follows a non-letter/non-digit boundary, using Unicode property escapes (\p{L}, \p{N}) so it works outside ASCII.

Str.title("hello world");   // "Hello World"
Str.ucfirst("foo Bar");     // "Foo Bar"  — only the first char is touched

ucfirst("") returns "" rather than throwing.

Identifiers

random(length = 16): string   // hex, from randomBytes
uuid(): string                // node:crypto randomUUID() — v4, fully random
uuid7(): string               // RFC 9562 v7 — 48-bit ms timestamp, then random
orderedUuid(): string         // alias for uuid7(), named for intent
ulid(): string                // 10 chars of time + 16 chars of randomness

Str.random()'s alphabet is hexadecimal only (0-9a-f), because it is randomBytes(ceil(n/2)).toString("hex").slice(0, n). It is cryptographically random but has ~4 bits of entropy per character, not ~6. For a token where density matters, generate bytes and base64url them yourself; for a filename suffix or a test fixture, this is fine.

Str.ulid() is lexicographically sortable by generation time, the first 10 characters are Date.now() in Crockford base32, the remaining 16 are 80 random bits. Two ULIDs generated in the same millisecond do not have a defined relative order (there is no monotonic counter). For guaranteed-ordered IDs, see @mahiframework/snowflake.

Str.uuid7() is the UUID-shaped equivalent: a 48-bit millisecond timestamp followed by 74 random bits, so ids sort in creation order while staying valid UUIDs. Prefer it to uuid() for a primary key, a v4 scatters inserts across the index at random, fragmenting it and dirtying a fresh page per write, whereas a v7 appends and keeps the hot leaf in cache. It also makes order by id a usable proxy for order by created_at without a second index.

A v7 leaks its creation time to anyone holding one. Harmless for a row id; not harmless for a value that doubles as a capability, like an unguessable share link, where a known timestamp narrows the search space. Use uuid() there.

Like ULIDs, two v7s minted in the same millisecond have no defined relative order. Ordering comes from the timestamp, uniqueness from the random bits. orderedUuid() is an alias, named after what you want rather than which RFC version currently provides it.

Arr

Utilities over arrays and plain objects. Five of its members are re-exports of the data_* family, so Arr.get/Arr.set are the same compile-time-checked functions documented in Dot-notation access.

Wrapping and flattening

wrap<T>(value: T | T[] | undefined | null): T[]
flatten<T>(value: readonly (T | readonly T[])[]): T[]

wrap() turns null/undefined into [], leaves arrays alone, and puts anything else in a one-element array. It is the idiomatic way to accept "one or many" at an API boundary:

Arr.wrap("admin");            // ["admin"]
Arr.wrap(["admin", "owner"]); // ["admin", "owner"]
Arr.wrap(null);               // []

flatten() is one level only, it's Array.prototype.flat() with the default depth of 1. For arbitrary depth use collect(x).flatten(), which takes a depth argument and defaults to Infinity.

Object key selection

only<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>
except<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K>

Both return a new object; neither mutates. only() skips keys that aren't present (k in obj), so the result never gains undefined entries for missing keys. The return types are Pick/Omit, so the narrowing is visible downstream.

Arr.only(user, ["id", "email"]);       // { id, email }
Arr.except(user, ["password"]);        // everything but password

except() compares stringified keys and iterates Object.entries, so it drops symbol keys and non-enumerable properties. It's for plain data, not class instances.

Dot-path access

MemberAliasesNotes
Arr.getdata_getRead a dotted path
Arr.setdata_setWrite a dotted path (mutates; use the return value)
Arr.filldata_fillWrite only if absent
Arr.hasdata_hasEvery path must exist
Arr.forgetdata_forgetRemove paths (mutates)

Plus two that only live on Arr:

hasAny<T>(target: T, keys: Paths<T> | readonly Paths<T>[]): boolean
pull<T, P>(target: T, key: P, fallback?): PathValue<T, P>

hasAny() is data_has's some() counterpart, data_has requires all the given paths, hasAny requires one.

pull() reads and removes in one call. It mutates target:

const options = { retries: 3, verbose: true };
const retries = Arr.pull(options, "retries");   // 3
// options is now { verbose: true }

first and last

first<T>(array, predicate?, fallback?): T | undefined
last<T>(array, predicate?, fallback?): T | undefined

These filter first, then index:

const source = predicate ? array.filter(predicate) : array;
if (source.length > 0) return source[0];
return fallback === undefined ? undefined : value(fallback);

Two consequences worth knowing. First, with a predicate they are O(n) even when the match is at index 0. The whole array is scanned and an intermediate array is allocated. For a hot loop use array.find(predicate). Second, fallback goes through the value() helper, so a function is called, not returned:

Arr.first(users, (u) => u.isAdmin);
Arr.last(logs, null, () => defaultEntry());   // thunk is invoked
Arr.first([], null, "none");                  // "none"

Pass null (not undefined) as the predicate when you want a fallback without filtering. undefined would be fine too, but null reads as deliberate.

List and shape tests

isList(value: DataValue): boolean
isAssoc(value: DataValue): boolean

isList() is true for any array, and for a plain object whose keys are exactly "0", "1", … "n-1" in order. Empty arrays and empty objects are both lists, matching PHP 8.1's array_is_list([]) === true:

Arr.isList([]);              // true
Arr.isList({});              // true   ← every() over zero keys is vacuously true
Arr.isList([1, 2, 3]);       // true
Arr.isList({ 0: "a", 1: "b" });  // true
Arr.isList({ 1: "a" });      // false  — doesn't start at 0
Arr.isList("hello");         // false  — not an object
Arr.isList(null);            // false

isAssoc() is the inverse for objects and arrays only, and false for everything else:

Arr.isAssoc({});             // false  ← {} is a list, so it is not assoc
Arr.isAssoc({ a: 1 });       // true
Arr.isAssoc([1, 2]);         // false
Arr.isAssoc(null);           // false
Arr.isAssoc(42);             // false

The isAssoc({}) === false case is the one that surprises people. {} and [] are indistinguishable as "an empty container", and PHP made the same call. If you need "is a non-array object", test that directly.

Dot and undot

dot(target: DataValue, prefix = ""): Record<string, DataValue>
undot(target: Record<string, DataValue>): DataObject | DataList

dot() flattens nested structure into a single-level map of dotted paths. Array indices become path segments:

Arr.dot({ user: { name: "Ada", roles: ["admin", "owner"] } });
// {
//   "user.name": "Ada",
//   "user.roles.0": "admin",
//   "user.roles.1": "owner",
// }

Empty containers are preserved as leaves rather than vanishing:

Arr.dot({ a: {}, b: [] });   // { "a": {}, "b": [] }

Without that rule dot()/undot() would not round-trip. An empty object would flatten to nothing and come back missing.

undot() is the inverse. It builds the structure with data_set and then collapses any object whose keys are "0".."n-1" back into a real array:

Arr.undot({ "user.name": "Ada", "user.roles.0": "admin" });
// { user: { name: "Ada", roles: ["admin"] } }

The rest

divide<T>(obj: T): [Array<keyof T & string>, Array<T[keyof T]>]
crossJoin<T>(...arrays: Array<readonly T[]>): T[][]
partition<T>(array, predicate): [T[], T[]]
sole<T>(array, predicate?): T
where<T>(array, predicate): T[]
whereNotNull<T>(array): T[]
query(obj: DataObject): string

divide() splits an object into parallel keys/values arrays, Object.keys and Object.values in one call, correctly typed.

crossJoin() produces every combination, seeded with [[]] so a zero-argument call returns [[]] (one empty combination), not []:

Arr.crossJoin([1, 2], ["a", "b"]);
// [[1,"a"], [1,"b"], [2,"a"], [2,"b"]]

partition() is one pass producing [passing, failing], and its predicate receives (item, index):

const [active, inactive] = Arr.partition(users, (u) => u.active);

sole() asserts exactly one match, throwing ItemNotFoundError for zero and MultipleItemsFoundError for two or more, the same two errors Collection.sole() uses.

const admin = Arr.sole(users, (u) => u.role === "admin");

where() is Array.prototype.filter with a (item, index) predicate. whereNotNull() drops null and undefined, and narrows the type via a type predicate:

const ids: string[] = Arr.whereNotNull(maybeIds);   // (string|null)[] → string[]

query() builds an application/x-www-form-urlencoded string with PHP bracket notation for nesting, skipping null/undefined entirely:

Arr.query({ filter: { status: "active" }, tags: ["a", "b"], page: 2 });
// "filter%5Bstatus%5D=active&tags%5B0%5D=a&tags%5B1%5D=b&page=2"

Values go through String(value), so true becomes "true" and a Date becomes its toString(). Format non-scalars before passing them in.

Collection

import { Collection, collect } from "@mahiframework/core";

A near 1:1 port of Laravel's Illuminate\Support\Collection, and the return type of every model query that yields more than one row. See Models.

It is an ordered Map, not an array

This is the design decision everything else follows from.

PHP arrays are ordered maps (array-key => value), which is why Laravel's Collection has key-based methods (get/put/has/forget/keys/ keyBy) sitting alongside list-style ones (push/map/filter). To support that faithfully, this class stores its items in an internal Map<K, V>:

export class Collection<V, K extends PropertyKey = number> {
  private items: Map<K, V>;
  private nextIndex: number;
  private constructor(items: Map<K, V>, nextIndex: number) { /* ... */ }
}

K defaults to number, so a freshly-make()d collection behaves like a 0-indexed list. Re-keying operations narrow K to the new key type, and the result keeps chaining:

const byEmail = collect(users).keyBy("email");   // Collection<User, string>
byEmail.get("ada@example.com");                   // User | undefined
byEmail.filter((u) => u.active).count();          // still a Collection

If keyBy() returned a native Map, the obvious TypeScript translation, the chain would stop dead there, and so would groupBy, countBy, mapWithKeys, pluck(value, key), flip, and combine. All of them return keyed Collections instead.

nextIndex mirrors PHP's $items[] = $value semantics: it tracks the next auto-assigned integer key, and put() advances it when you write an integer key at or beyond it.

Construction

The constructor is private. Build one through a static:

StaticSignatureNotes
make(items: readonly V[])Keys 0..n-1
make(items: Iterable<[K, V]>)Preserves keys, a Map, .entries(), or another Collection
empty<V>()Zero items, nextIndex = 0
wrap(Collection | V[] | V)Scalars become a one-item collection
unwrap(Collection | V[])The plain array back out
range(from, to, step = 1)Inclusive of to; negative step counts down
times(number, callback?)1-indexed; number < 1 gives an empty collection
fromJson(json: string)Must decode to an array

make() defensively clones its input into a fresh Map. Mutating the result never touches the caller's original array, iterable, or collection:

const source = [1, 2, 3];
const c = Collection.make(source).push(4);
source.length;   // still 3

collect() is the shorthand, and the one you'll write most:

import { collect } from "@mahiframework/core";

collect(users);           // Collection<User, number>
collect(null);            // Collection<never, number> — empty, not a crash
collect("one value");     // one-item collection

collect(null) and collect(undefined) return an empty collection; every other value goes through Collection.wrap().

What mutates

Mutability matches Laravel exactly, method by method. Twelve methods mutate in place; every other method returns a new Collection (or a plain value).

MutatingReturns
push, add, unshift, prependthe same collection (this)
pop, shiftthe removed item, or a Collection of removed items
splicea Collection of the removed portion
put, forget, transformthis
pullthe removed value
getOrPutthe existing or newly-stored value

Everything else: map, filter, reject, where*, sort*, unique, merge, slice, take, chunk, values, keys, only, except, is non-destructive.

The trap this creates:

const items = collect([3, 1, 2]);

items.sort();      // new collection; `items` is untouched
items.push(4);     // MUTATES items, and returns it

sort() returning a new instance while push() mutates looks inconsistent, and it is, but it's the same inconsistency Laravel has, and diverging would silently break every ported snippet. When in doubt, assign the result: const sorted = items.sort() is correct either way.

List-shaped methods are type-restricted

push, add, unshift, pop, and shift are declared with an explicit this type:

push(this: Collection<V, number>, ...values: V[]): Collection<V, number>

They are therefore only callable when K = number. Pushing onto a string-keyed collection is a compile error, not a silent reinterpretation of the key space the way PHP's array_push() behaves:

const byEmail = collect(users).keyBy("email");   // Collection<User, string>
byEmail.push(newUser);
// Error: 'this' context of type 'Collection<User, string>' is not
// assignable to method's 'this' of type 'Collection<User, number>'.

Use put(key, value) on a keyed collection. prepend(value, key?) works on both, with a key it inserts that one entry at the front and leaves every other key alone; without one it renumbers from 0 like unshift.

all() and toArray() discard keys

all(): V[]        // [...this.items.values()]
toArray(): V[]    // alias for all()
toJSON(): V[]     // makes JSON.stringify(collection) work
toJson(indent?): string

All four are values only. A keyed collection loses its keys the moment you call any of them:

const byEmail = collect(users).keyBy("email");
byEmail.all();                    // User[] — the emails are gone
JSON.stringify(byEmail);          // "[{...},{...}]" — an array, not an object

If you need the pairs, go through keys():

for (const email of byEmail.keys()) {
  const user = byEmail.get(email);
}

Object.fromEntries(byEmail.keys().all().map((k) => [k, byEmail.get(k)]));

Iterating a collection directly (for (const item of collection)) also yields values, via [Symbol.iterator] returning items.values().

Method groups

Basic accessors. all, toArray, toJSON, toJson, length (getter), count, isEmpty, isNotEmpty.

Key access. get(key, default?), getOrPut(key, value) (mutates), has(key | key[]) (every key must exist), hasAny(keys) (any), put (mutates), pull (mutates), forget (mutates), only(keys), except(keys), keys(), values() (renumbers to 0..n-1).

get() and pull() accept a thunk as the default, evaluated only on a miss:

collection.get("missing", () => expensiveDefault());

Iteration. each (return false to break early), map, mapWithKeys, mapToDictionary, mapToGroups, flatMap, mapSpread, mapInto(ctor), eachSpread, reduce, reduceInto, reduceSpread.

Every callback receives (item, key). map() always returns a list-shaped Collection<U, number>. It renumbers. Use mapWithKeys() to derive keys, or mapValues() to keep the ones you have:

collect(users).mapWithKeys((u) => [u.email, u.name]);   // Collection<string, string>

keyed.mapValues((n) => n * 2);                          // same keys, new values
keyed.map((n) => n * 2);                                // keys become 0, 1, 2...

mapValues() is the one to use on a keyed collection: map() discards exactly the thing that made it keyed, and mapWithKeys((v, k) => [k, f(v)]) restates the key only to say "unchanged".

Building a keyed collection needs a Map. Collection.make() takes an array, another Collection, or an iterable of entries, and Object.entries() returns an array, so it matches the array overload and gives you a list of [key, value] pairs:

Collection.make(Object.entries({ a: 1 }));           // Collection<[string, number], number>
Collection.make(new Map(Object.entries({ a: 1 })));  // Collection<number, string>

collect() cannot produce a keyed collection at all. Its return type is always Collection<V, number>.

Filtering. filter(callback?) (no callback drops falsy values), reject, where(key, value) / where(key, operator, value), whereNull, whereNotNull, whereIn, whereNotIn, whereBetween, whereNotBetween, whereInstanceOf(ctor).

Supported where operators: =, ==, ===, !=, <>, !==, <, >, <=, >=. The three equality spellings are all strict === and the three inequality spellings are all !==, PHP's loose == has no place here, so the pairs are collapsed rather than faked.

collect(posts).where("published", true);
collect(posts).where("views", ">", 1000);
collect(mixed).whereInstanceOf(Post);   // narrows to Collection<Post, K>

Searching and testing. first, firstOrFail, firstWhere, last, sole, hasSole, hasMany, contains, some (alias), doesntContain, every, search, before, after, ensure.

search() returns the key, or false when absent, so always compare with === false, never a truthiness check (key 0 is falsy):

const key = collection.search((u) => u.id === target);
if (key === false) { /* not found */ }

contains/some/doesntContain/every/reject accept either a predicate or a plain value to compare with ===.

Grouping and keying. groupBy, keyBy, countBy, partition, duplicates. Each selector is a property key or a (item, key) callback.

collect(posts).groupBy("authorId");        // Collection<Collection<Post>, string>
collect(posts).groupBy((p) => p.createdAt.slice(0, 7));  // by month
collect(words).countBy((w) => w.length);   // Collection<number, number>

groupBy and mapToGroups return a Collection of Collections; mapToDictionary returns a Collection of plain arrays.

Aggregates. sum, avg, average (alias), min, max, median, mode, percentage(callback, precision = 2).

All take an optional selector. avg/min/max/median skip null/undefined values and return undefined for an empty (or all-null) collection; sum returns 0. mode() returns a sorted array of the most-frequent values, since ties are real.

Collection.percentage() returns a number from 0 to 100, the share of items passing the predicate:

collect(posts).percentage((p) => p.published);   // 66.67

Slicing and paging. take (negative takes from the end), skip, slice(offset, length?), forPage(page, perPage) (1-indexed), takeUntil, takeWhile, skipUntil, skipWhile, nth(step, offset = 0).

All of these preserve keys except nth, which renumbers.

Chunking and splitting. chunk(size), chunkWhile(callback), sliding(size = 2, step = 1), split(n) (roughly equal groups, remainder to the front), splitIn(n) (fill earlier groups completely first). Each returns a Collection of Collections.

Ordering. sort(compareFn?), sortDesc, sortBy(selector, desc = false), sortByDesc, sortKeys(desc = false), sortKeysDesc, sortKeysUsing, reverse, shuffle, random(count?).

All preserve keys. The default comparator subtracts numbers and otherwise compares String(a) against String(b), so mixed-type sorting is stringly, and [10, 9] sorts numerically as long as both are numbers.

random() with no argument returns one item (or undefined when empty); with a count it returns a Collection sampled without replacement.

Uniqueness. unique(selector?), keeps the first occurrence and preserves its key.

Merging and set operations. merge (later wins), mergeRecursive, union (existing keys win, the opposite bias from merge), replace/replaceRecursive (aliases of merge), concat (appends values, discarding source keys), multiply(n), diff, diffUsing, intersect, intersectUsing, crossJoin, combine, flip, zip, pad(size, value) (negative size pads at the front).

merge versus union is the one to keep straight:

collect(["a", "b"]).merge(["x"]).all();   // ["a", "b", "x"] — appended at nextIndex
collect(["a", "b"]).union(["x"]).all();   // ["a", "b", "x"]

With overlapping keys they differ: merge overwrites, union keeps what's already there.

Flatten and collapse. flatten(depth = Infinity), collapse() (one level). Both understand nested Collections as well as arrays.

Column extraction. pluck(value) / pluck(value, key), select(keys).

collect(users).pluck("email");             // Collection<string, number>
collect(users).pluck("name", "id");        // Collection<string, string>
collect(users).select(["id", "email"]);    // Collection<Pick<User, "id"|"email">, K>

String conversion. implode(keyOrFn, glue = ""), join(glue, finalGlue = "").

collect(users).implode("name", ", ");        // "Ada, Grace, Alan"
collect(["a", "b", "c"]).join(", ", " and "); // "a, b and c"

Flow control. pipe(callback), pipeInto(ctor), pipeThrough(fns), tap(callback), when(value, cb, default?), unless, whenEmpty, whenNotEmpty, unlessEmpty, unlessNotEmpty.

collect(posts)
  .when(filters.authorId, (c, id) => c.where("authorId", id))
  .when(filters.published, (c) => c.where("published", true))
  .sortByDesc("createdAt");

tap() returns the collection unchanged, which makes it the debugging hook: .tap((c) => console.log(c.count())).

ItemNotFoundError and MultipleItemsFoundError

import { ItemNotFoundError, MultipleItemsFoundError } from "@mahiframework/core";
ErrorThrown byMessage
ItemNotFoundErrorfirstOrFail(), sole(), Arr.sole()"Item not found."
MultipleItemsFoundErrorsole(), Arr.sole()"3 items found."

MultipleItemsFoundError carries a public count property, so a handler can report how many it found without parsing the message.

try {
  const owner = collect(members).sole((m) => m.role === "owner");
} catch (error) {
  if (error instanceof MultipleItemsFoundError) {
    reportDataIntegrityIssue(error.count);
  }
}

What isn't ported

dd/dump (use console.log), __toString, getCachingIterator, ArrayAccess offsets (use get/put/has/forget), Macroable, lazy()/LazyCollection, toBase(), collapseWithKeys(), dot/undot (they're on Arr), the *Assoc/*Keys/intersectByKeys set-op variants, and HigherOrderCollectionProxy, the __call-forwarding magic that no type checker can follow, rejected for the same reason dynamic facades are.

Num

import { Num } from "@mahiframework/core";

A thin wrapper over Node's built-in Intl.NumberFormat, plus three hand-rolled helpers Intl has no equivalent for.

Use Num, not Number

The helper is exported as Num. It is deliberately not named Number: importing a Number export shadows the global Number constructor in that module, so a later Number.isInteger(...) / Number(x) becomes a runtime TypeError.

import { Num } from "@mahiframework/core";

Num.format(1234.5);        // "1,234.5"  — the helper
Number.isInteger(4);       // true — the global is untouched

A deprecated Number alias still exists for backward compatibility, but new code should import Num.

NumberFormatOptions

interface NumberFormatOptions {
  locale?: string;       // BCP 47 tag; defaults to the host's
  precision?: number;    // pins BOTH min and max fraction digits
  maxPrecision?: number; // caps max fraction digits only
}

precision wins when both are given. maxPrecision is only consulted when precision is undefined. precision: 2 on 1.5 gives "1.50"; maxPrecision: 2 gives "1.5".

The methods

format(value, options?): string
currency(value, currency = "USD", options?): string
percentage(value, options?): string
fileSize(bytes, precision = 0): string
abbreviate(value, precision = 0): string
clamp(value, min, max): number
Number.format(1234567.891);                       // "1,234,567.891"
Number.format(1234.5, { precision: 2 });          // "1,234.50"
Number.format(1234.5, { locale: "de-DE" });       // "1.234,5"

Number.currency(1234.5);                          // "$1,234.50"
Number.currency(1234.5, "NZD", { locale: "en-NZ" }); // "$1,234.50"
Number.currency(1234.5, "EUR", { locale: "de-DE" }); // "1.234,50 €"

percentage() takes a percentage, not a fraction

Number.percentage(10);                      // "10%"    — NOT "1,000%"
Number.percentage(10.5, { precision: 1 });  // "10.5%"

This matches Laravel's Number::percentage(10) and deliberately breaks from Intl's convention, where style: "percent" multiplies by 100 and expects a 0–1 fraction. The implementation formats the number normally and appends a literal %:

percentage(value, options) {
  const precision = options?.precision ?? 0;
  return `${formatter({ ...options, precision }).format(value)}%`;
}

So a ratio must be scaled first: Number.percentage(ratio * 100).

Note precision defaults to 0 here (not to Intl's default), so percentage(66.666) is "67%" unless you ask for decimals.

fileSize() uses binary units, abbreviate() uses decimal ones

They deliberately differ, because the two domains do:

DivisorUnits
fileSize1024B, KB, MB, GB, TB, PB, EB
abbreviate1000(none), K, M, B, T, Q
Number.fileSize(1024);            // "1 KB"
Number.fileSize(2_400_000);       // "2 MB"
Number.fileSize(2_400_000, 2);    // "2.29 MB"

Number.abbreviate(1000);          // "1K"
Number.abbreviate(1_500_000);     // "2M"
Number.abbreviate(1_500_000, 1);  // "1.5M"
Number.abbreviate(-2500);         // "-3K"

The labels say KB/MB while dividing by 1024, the same choice most operating systems make and the one users expect from a file browser. It is technically KiB.

Two behaviours to know:

fileSize promotes at 0.9 of a unit, not at 1.0. The loop condition is amount / 1024 > 0.9, so anything above 921.6 bytes is rendered in KB:

Number.fileSize(1000);   // "1 KB"   — not "1,000 B"
Number.fileSize(900);    // "900 B"

fileSize returns "0 B" for negatives and non-finite input rather than throwing. abbreviate handles negatives properly, extracting the sign before scaling.

clamp(value, min, max) is Math.min(max, Math.max(min, value)), no validation that min <= max, so an inverted range returns min.

Dot-notation access

import { data_get, data_set, data_fill, data_has, data_forget } from "@mahiframework/core";

Laravel's data_* global helpers, with one enormous difference: the path is checked against T at compile time, and the return type is the type at that path.

const user = {
  name: "Ada",
  address: { country: "NZ", city: "Wellington" },
  roles: ["admin", "owner"],
};

data_get(user, "address.country");   // string
data_get(user, "roles.0");           // string
data_get(user, "roles.*");           // string[]
data_get(user, "address.postcode");  // ❌ compile error — no such path
data_get(user, "adress.country");    // ❌ compile error — typo caught

The camelCase spellings (dataGet, dataSet, dataFill, dataHas, dataForget) still exist and still work, but are @deprecated aliases. Prefer the snake_case names. They match Laravel and they're what the rest of the framework uses.

The type machinery

Two exported types do the work:

Paths<T, D = 6>          // every valid dotted path through T
PathValue<T, P>          // the type at path P
PathAssigned<T, P>       // the type data_set accepts at path P

Worth knowing about their limits:

Depth is capped at 7 segments. Paths<T> recurses through a Depth tuple that bottoms out, keeping type instantiations bounded. A path deeper than that isn't offered.

Index signatures open the gate. Paths<Record<string, unknown>> is plain string, and PathValue<Record<string, unknown>, anything> is unknown. That's exactly why app.config.get("database.default") compiles against an untyped config shape. There's nothing to check against, so nothing is rejected.

PathAssigned differs from PathValue at wildcards. Reading "users.*.name" gives string[]; writing it takes a single string, applied to every match.

data_get(data, "users.*.name");        // string[]
data_set(data, "users.*.name", "Ada"); // one string → every user

Wildcards

* iterates an array's elements or an object's values. A path with a second * collapses one array level, matching Laravel:

data_get({ users: [{ name: "Ada" }, { name: "Grace" }] }, "users.*.name");
// ["Ada", "Grace"]

data_get(teams, "teams.*.members.*.email");
// string[] — flat, not string[][]

Missing keys under a wildcard produce the fallback value in that slot rather than being skipped, so the result array's length matches the number of iterated entries.

Traversal walks plain objects and arrays only

function isPlainObject(value: DataValue): value is DataObject {
  if (value === null || typeof value !== "object") return false;
  const proto = Object.getPrototypeOf(value);
  return proto === Object.prototype || proto === null;
}

An object whose prototype is anything else, a Date, a Map, a Set, a model instance, any class instance, is a leaf. data_get will return it, but will not walk into it:

data_get({ at: new Date() }, "at");            // Date — returned whole
data_get({ at: new Date() }, "at.getTime");    // ❌ compile error; Date is a Primitive
data_get({ user: userModel }, "user.email");   // does not traverse the model

This is deliberate. Accessing class internals by string path is how you end up depending on private state, and there is no reflection here to make it safe. Call the object's own accessors instead.

The type layer agrees: Date, functions, Map, and Set are all in the Primitive union, so Paths<T> never offers a path through them.

data_get

data_get(target, null | undefined | "")         // target itself
data_get(target, path)                          // PathValue<T, P>
data_get(target, path, fallback)                // PathValue<T, P> | F
data_get(target, ["a", "b"])                    // tuple form, also checked

fallback is returned for a runtime-missing key. A path that doesn't exist on T is a compile error regardless, for keys that may genuinely be absent, make the property optional on the type and pass a fallback:

type Config = { cache?: { store?: string } };
data_get(config, "cache.store", "array");   // string

Passing null/undefined/"" as the key returns the target unchanged, which makes data_get safe to call with a dynamic path that might be empty.

data_set mutates: and you should still use the return value

data_set(target, path, value, overwrite = true): T

When target is already an object or array, the write is in place. When it isn't, a fresh structure is built and returned, leaving the original alone:

const root: Accessible = isAccessible(target)
  ? target
  : isIndexSegment(segments[0]) && segments[0] !== "*" ? [] : {};

JavaScript has no PHP-style pass-by-reference for a primitive or a class instance, so the only way to receive that fresh structure is the return value. Always write target = data_set(target, ...). It's a no-op in the common case and correct in the uncommon one:

let payload = data_set(payload, "meta.source", "api");

Missing intermediate containers are created on the way down, and the choice of [] versus {} is driven by the next segment: a numeric or * segment creates an array, anything else creates an object.

const out = data_set({}, "items.0.name", "Ada");
// { items: [{ name: "Ada" }] }

data_fill

data_set with overwrite = false. Existing leaves are left alone; missing ones are created. Same "use the return value" rule:

settings = data_fill(settings, "notifications.email", true);

data_has

data_has(target, path)              // one path
data_has(target, [pathA, pathB])    // ALL must exist

Returns true when the key is present, even if its value is null or undefined. An empty array of paths is false, as is a non-object target.

data_has does not evaluate wildcards. Its walker compares segments literally, so "users.*.name" asks whether there's a key spelled "*". data_get and data_forget both handle *; data_has does not. Use data_get(target, "users.*.name", []).length > 0 instead.

data_forget

data_forget(target, path): T
data_forget(target, [pathA, pathB]): T

Mutates and returns target. Deleting an array element splices it, shifting later indices down. It does not leave a hole:

const data = { items: ["a", "b", "c"] };
data_forget(data, "items.1");
// { items: ["a", "c"] }

Wildcards work here: data_forget(users, "users.*.password") strips the field from every element.

Global helpers

import { blank, filled, value, withValue, tap, retry, collect } from "@mahiframework/core";

blank / filled

blank(value: Blankable): boolean
filled(value: Blankable): boolean   // !blank(value)

Laravel's blank(), including its two famous exceptions. false and 0 are NOT blank:

blank(false);      // false ← a boolean is a real value
blank(0);          // false ← so is zero
blank(0n);         // false

blank(null);       // true
blank(undefined);  // true
blank("");         // true
blank("   ");      // true ← whitespace-only strings are trimmed first
blank([]);         // true
blank({});         // true
blank(new Map());  // true
blank(new Set());  // true
blank(collect([])); // true ← Collection.isEmpty()

Those two exceptions are the entire reason the helper exists. if (!x) treats 0 and false as absent, which is right for a truthiness check and wrong for "did the user supply a value". A checkbox that's off and a count of zero are both answers.

The Blankable type covers every runtime branch the function has: string | number | boolean | bigint | object | null | undefined. symbol is deliberately excluded, passing one is a type error rather than a silent false.

value

value<T>(val: T | (() => T)): T
value<T, A>(val: (arg: A) => T, arg: A): T
value<T, A, B>(val: (a: A, b: B) => T, a: A, b: B): T

Resolve a value-or-thunk. If it's a function, call it (with any extra arguments); otherwise return it as-is. This is what makes "default may be lazy" work throughout the framework, Arr.first's fallback, Collection.get's default:

value(42);                          // 42
value(() => expensiveDefault());    // expensiveDefault()
value((n: number) => n * 2, 21);    // 42

withValue

withValue<T, R>(value: T, callback: (value: T) => R): R
withValue<T>(value: T): T

Pass a value through a callback and return the callback's result. This is Laravel's with($value, $callback).

It is named withValue because with is a reserved word in JavaScript. There is no way to export a function called with; the parser rejects it in strict mode and every ESM module is strict.

const slug = withValue(post.title, (t) => Str.slug(t));
const untouched = withValue(post.title);   // no callback → the value itself

The distinction from tap() is the return value:

Returns
withValue(v, cb)cb(v)
tap(v, cb)v

tap

tap<T>(value: T, callback?: (value: T) => void): T

Run a callback for its side effects and return the original value. The top-level equivalent of Collection.tap(), usable on anything:

return tap(await User.create(attributes), (user) => {
  logger.info("user created", { id: user.id });
});

The callback is optional, so tap(x) is just x.

retry

retry<T>(
  times: number,
  callback: (attempt: number) => T | Promise<T>,
  sleepMs: number | readonly number[] | ((attempt: number, error: unknown) => number) = 0,
  when?: (error: unknown) => boolean,
): Promise<T>

times is the total number of attempts, and the first call counts. retry(3, fn) calls fn at most three times, not four. times < 1 throws a RangeError immediately.

The callback receives the 1-based attempt number, which is useful for logging or for varying the request:

const response = await retry(3, (attempt) => {
  logger.debug("fetching", { attempt });
  return fetchUpstream();
});

sleepMs as an array is per-attempt backoff, and the last value is reused if attempts outlast the array:

await retry(4, () => callFlakyApi(), [100, 500, 2000]);
// waits 100ms after attempt 1, 500ms after 2, 2000ms after 3
const delay = typeof sleepMs === "number"
  ? sleepMs
  : (sleepMs[attempt - 1] ?? sleepMs.at(-1) ?? 0);

A 0 delay skips the timer entirely rather than scheduling a zero-millisecond one.

when gates retryability. Return false and the error rethrows immediately, without consuming the remaining attempts. Use it to distinguish transient failures from permanent ones:

await retry(
  5,
  () => chargeCard(payment),
  [1000, 2000, 4000, 8000],
  (error) => error instanceof NetworkError,   // don't retry a declined card
);

error is typed unknown, because a catch clause is unknown. A callback may throw anything, not only an Error.

sleepMs as a function computes the delay from the attempt number and the error that caused it. Which is how a caller honours a server's Retry-After header:

await retry(3, () => callApi(), (attempt, error) =>
  retryAfterFrom(error) ?? attempt * 1000,
);

pooled

pooled<T>(
  tasks: ReadonlyArray<() => Promise<T>>,
  options?: { concurrency?: number },
): Promise<Array<T | Error>>

pooled<T, K extends string>(
  tasks: Record<K, () => Promise<T>>,
  options?: { concurrency?: number },
): Promise<Record<K, T | Error>>

Runs async tasks with an optional concurrency cap, preserving array position or record keys:

const [users, posts] = await pooled([() => fetchUsers(), () => fetchPosts()]);

const { user, repos } = await pooled(
  { user: () => fetchUser(), repos: () => fetchRepos() },
  { concurrency: 2 },
);

It takes thunks, not promises. A Promise is already running by the time you hold one, so an array of promises cannot be concurrency-limited. () => Promise<T> defers construction, which is the whole mechanism.

A rejection lands as an Error value rather than rejecting the pool, so one failure never discards the other results:

const results = await pooled([() => ok(), () => fails(), () => alsoOk()]);
// ["ok", Error: boom, "also ok"]

for (const result of results) {
  if (result instanceof Error) continue;
  use(result);
}

Values thrown that aren't Errors are wrapped in one, since throw accepts anything. concurrency defaults to unlimited; 1 is strictly sequential; below 1 throws a RangeError.

This is the general form of Laravel's Http::pool(). Pooling has nothing to do with HTTP, and queue batches and storage uploads want the same thing. Http.pool() is a typed wrapper over it.

collect

collect<V>(value?: Collection<V, PropertyKey> | readonly V[] | V | null): Collection<V, number>

Collection.wrap(), with null/undefined mapping to an empty collection instead of a one-item one. See Collection.

Path helpers

import { base_path, storage_path, resource_path, database_path } from "@mahiframework/core";
HelperResolves to
base_path(...segments)the app root (process.cwd() by default) + segments
storage_path(...segments)base_path("storage", ...)
resource_path(...segments)base_path("resources", ...)
database_path(...segments)base_path("database", ...)

null and undefined segments are stripped, so conditional segments can be passed inline:

base_path("storage", isProduction && "prod-cache");
storage_path("logs", "mahi.log");

These resolve against process.cwd(), not against an Application instance. That's deliberate: config files call these helpers while building the object that's handed to app.config.set(...), which happens before app.bootstrap() and long before the app() global singleton is populated. Tying them to cwd keeps them usable at any point in the boot sequence, with no ordering trap:

// config/storage.ts — runs before the Application exists
export function storageConfig(): StorageConfig {
  return {
    default: "public",
    disks: {
      local: { root: storage_path("app/private") },
      public: { root: storage_path("app/public"), url: "/storage" },
    },
  };
}

The corollary: the app must be started from its own root directory. ./artisan handles this (it cds to its own directory first), and bin/server.js should be run the same way. See Deployment.

An app that cannot satisfy that, a CLI installed on PATH, or a compiled binary run from anywhere, calls setBasePath(root) as the first statement of its bootstrap() instead, and the other three helpers follow it. See Configuration → setBasePath().

@mahiframework/pipeline

import { Pipeline, Hub } from "@mahiframework/pipeline";

Send a value through an ordered list of pipes, each of which may transform it, short-circuit, or post-process on the way back out. This is the mechanism @mahiframework/http builds its middleware stack on. See Routing.

Pipeline

const result = await new Pipeline<Request, Response>()
  .send(request)
  .through([authenticate, throttle, logRequest])
  .run((req) => handleRequest(req));
MethodEffect
send(passable)The value to push through. Required before run().
through(pipes)Replaces the pipe stack wholesale, in run order.
pipe(pipe)Appends one pipe to the end.
run(destination)Runs it. Returns the destination's (or a short-circuiting pipe's) result.
thenReturn()Runs with an identity destination, for transform-only pipelines.

Calling run() without send() throws "Pipeline.send() must be called before run().".

The terminal method is run(), not then(). A then() method would make Pipeline a thenable, so await pipeline would execute it implicitly and an un-send()'d one would hang. Keep then off the pipeline.

A pipe is either a function or an object with a handle method:

type PipeFn<P, R> = (passable: P, next: Next<P, R>) => Promise<R> | R;
interface PipeObject<P, R> { handle: PipeFn<P, R>; }

Each pipe decides what happens next:

// Carry on, transforming on the way in and out
const trace: PipeFn<Request, Response> = async (req, next) => {
  const res = await next(req);
  res.headers.set("X-Traced", "1");
  return res;
};

// Short-circuit — later pipes and the destination never run
const requireAuth: PipeFn<Request, Response> = (req, next) =>
  req.user() ? next(req) : HttpResponse.json({ message: "Unauthorized" }, 401);

The chain is built right-to-left with reduceRight, so each pipe's next closes over the pipe after it, terminating in destination. Class-based pipes have handle bound to their instance, so this works.

Hub

A registry of named pipelines, so a call site can invoke one by name instead of assembling a Pipeline from scratch.

const hub = new Hub();

hub.pipeline("ingest", (pipeline, payload) =>
  pipeline.send(payload).through([validate, normalise, enrich]).thenReturn(),
);

const clean = await hub.pipe(rawPayload, "ingest");
MethodEffect
pipeline(name, factory)Register (or replace) a named pipeline.
defaults(factory)Register the "default" pipeline.
pipe(passable, name = "default")Run it. Throws if unregistered.
has(name)Whether a name is registered.

The registered factory receives a fresh Pipeline per call plus the passable, and is responsible for send/through/then itself. An unregistered name throws `Pipeline [${name}] is not registered on this Hub.`.

@mahiframework/process

import { Process, makeProcessResult, ProcessFailedError } from "@mahiframework/process";

A wrapper over node:child_process for running external commands, with a fake()/assertRan() pair for tests. No execa dependency, no dependency on @mahiframework/core. It's usable standalone.

const result = await Process.run(["git", "rev-parse", "HEAD"]);
if (result.successful()) console.log(result.stdout.trim());

Array form is safe; string form uses a shell

static run(command: string | string[], options?: ProcessOptions): Promise<ProcessResult>

This is the single most important thing about the API:

FormSpawned asShell?
["git", "log", userInput]spawn(argv[0], argv.slice(1))No
"git log | head -5"spawn(cmd, { shell: true })Yes

Use the array form by default. With no shell, arguments are passed to the process directly. There is no word splitting, no glob expansion, no ;/&&/$() interpretation, and therefore no shell injection. A user value that happens to contain ; rm -rf / is one argument containing that text.

The string form exists for the cases where you genuinely want shell features, pipes, redirects, globs:

await Process.run(["pg_dump", "--no-owner", databaseName]);       // safe
await Process.run("pg_dump mydb | gzip > backup.sql.gz");         // shell needed
await Process.run(`pg_dump ${userSupplied}`);                     // ⚠️ injection

ProcessOptions

OptionTypeMeaning
cwdstringWorking directory. Defaults to the current process's.
timeoutMsnumberSIGTERM the child after this long.
envRecord<string, string>Merged on top of process.env, not a replacement.
inputstringWritten to stdin, which is then closed.

stdin is closed either way, a child waiting on input won't hang forever just because you didn't pass any.

run() never rejects

child.on("error", (error) => {
  resolve(makeProcessResult(commandString, 1, stdout, stderr || error.message));
});

A failed spawn (command not found, permission denied) resolves with exitCode: 1 and the error's message in stderr, exactly like a non-zero exit. There is one code path for "it didn't work":

const result = await Process.run(["nonexistent-binary"]);
result.failed();     // true
result.exitCode;     // 1
result.stderr;       // "spawn nonexistent-binary ENOENT"

Opt into throwing with .throw(), which raises ProcessFailedError (carrying the full result) when the exit code isn't zero:

const { stdout } = (await Process.run(["git", "rev-parse", "HEAD"])).throw();

Exit code 1 is overloaded

Node reports "killed by a signal" and "never started" both as a null exit code, so this package collapses them, along with a real exit status 1, into exitCode: 1:

child.on("exit", (code) => {
  resolve(makeProcessResult(commandString, code ?? 1, stdout, stderr));
});

Three distinct outcomes therefore look identical:

  • the process ran and exited 1
  • the process was killed by a signal, including your own timeoutMs
  • the process never started (ENOENT)

There is no more specific POSIX convention worth inventing. If you need to tell them apart, inspect stderr, a spawn failure carries the Node error message, and a timeout leaves whatever the child had written so far.

ProcessResult

interface ProcessResult {
  command: string;    // joined argv, or the raw string
  exitCode: number;
  stdout: string;
  stderr: string;
  successful(): boolean;   // exitCode === 0
  failed(): boolean;       // exitCode !== 0
  throw(): ProcessResult;  // no-op on success; throws ProcessFailedError otherwise
}

makeProcessResult(command, exitCode, stdout, stderr) builds one. You'll need it to define fake handlers.

Output is buffered entirely in memory as a string. There is no streaming interface, and no pipe()/pool()/background-process support; this is scoped to "run a command, wait for it, read what it said".

Faking in tests

Process.fake(handlers?: Record<string, FakeProcessHandler>): void
Process.isFaked(): boolean
Process.ran(): readonly string[]
Process.assertRan(matcher: string | ((command: string) => boolean)): void
Process.assertNotRan(matcher: string | ((command: string) => boolean)): void
Process.restore(): void

fake() swaps run() to resolve from the handler map instead of spawning anything. Keys are *-wildcard patterns matched against the joined command string; the first matching handler wins.

import { Process, makeProcessResult } from "@mahiframework/process";

beforeEach(() => {
  Process.fake({
    "git rev-parse *": makeProcessResult("git rev-parse HEAD", 0, "abc123\n", ""),
    "npm run *": (command) => makeProcessResult(command, command.includes("test") ? 1 : 0, "", ""),
  });
});

afterEach(() => Process.restore());

it("records the deployed commit", async () => {
  await deploy();
  Process.assertRan("git rev-parse *");
  Process.assertNotRan("rm *");
});

A handler is either a fixed ProcessResult or a function of the actual command string, sync or async. The function form is how you vary the response per invocation.

An unmatched command under fake() resolves successfully with empty output rather than throwing. Process.fake() with no arguments therefore stubs out every command as a silent success, which is the right default for "this code shells out and I don't care what it says".

Process.ran() records real runs too. History is appended on every run() call whether faked or not, and is only cleared by restore(). That has two consequences: assertions work against real runs as well as fakes, and a long-lived process that never calls restore() accumulates command strings indefinitely. Always pair fake() with restore() in an afterEach.

Assertions throw a plain Error rather than using a test-runner matcher, so the package stays runner-agnostic. Failure messages list what actually ran:

Expected a process matching "git push *" to have run. Ran: git status, git add .

@mahiframework/tui

import { Tui } from "@mahiframework/tui";

A from-scratch port of laravel/prompts, notes, prompts, tables, spinners, and progress bars, exposed through one static class. It talks directly to process.stdin/process.stdout and depends on nothing else in the framework.

Tui.intro("Deploying");
const env = await Tui.select("Which environment?", { options: ["staging", "production"] });
if (await Tui.confirm(`Deploy to ${env}?`)) {
  await Tui.task("Building", () => build());
  await Tui.spinner("Uploading", () => upload());
}
Tui.outro("Done");

The surface, in groups:

GroupMethods
Messagesnote, error, warning, info, success, intro, outro, display(message, type)
Promptsask, select, confirm, secret
Progressprogress(label, total) / progress(label, items, callback), spinner, task, taskLine
Outputtable(headers, rows) / table(rows)
Testinginteractive(value = true), fake(keys?)

Tui.fake(keys) swaps in a buffered output and a fake terminal that yields the given keypresses, and forces interactive mode so prompts take their TTY code path under a non-TTY test runner. It returns a handle with output(), strippedOutput() (ANSI removed), and restore(). See Testing.

Console commands get these through the Command base class rather than importing Tui directly. The full treatment, writing commands, output styling, the make:* generators, is in Console.

Gotchas

Importing Number shadows the global. Use globalThis.Number for isInteger/parseFloat/etc. in the same module, or alias the import.

Number.percentage(10) is "10%", not "1,000%". It takes a percentage, not an Intl-style 0–1 fraction. Scale ratios yourself.

Number.fileSize promotes at 921.6 bytes. The loop threshold is amount / 1024 > 0.9, so fileSize(1000) is "1 KB".

Arr.isList({}) and Arr.isList([]) are both true, so Arr.isAssoc({}) is false. An empty container is a list, matching PHP.

Arr.flatten is one level only. Use collect(x).flatten() for deeper.

Arr.first/Arr.last with a predicate scan the whole array. They filter, then index. Use array.find() in hot paths.

Twelve Collection methods mutate. push, add, unshift, prepend, pop, shift, splice, put, pull, forget, transform, getOrPut. Everything else returns a new collection. Assign the result either way.

Collection.all()/toArray()/toJSON() discard keys. A keyBy()d collection serialises as a JSON array, not an object.

push/pop/shift/unshift/add don't exist on keyed collections. That's a compile error by design. Use put(key, value).

Collection.search() returns false, not -1, when absent, and key 0 is falsy. Compare with === false.

data_has ignores wildcards. It matches * as a literal key. Use data_get(..., []) and check the length.

data_set returns a new structure when the target isn't an object or array. Always use the return value.

data_get does not traverse Dates, Maps, Sets, or class instances. They're leaves, returned whole, never walked into.

blank(0) and blank(false) are false. That's the point of the helper; if (!x) is what you use when you do want truthiness.

retry(3, fn) calls fn three times, not four. The first attempt counts toward times.

Str.random() is hex only. ~4 bits of entropy per character.

Str.slug() is ASCII-only. Non-Latin scripts slug to "".

Process.run() never rejects. Check result.failed() or call .throw().

A string command runs through a shell. Pass an argv array for anything built from user input.

Process.ran() keeps growing until restore(). Always afterEach(() => Process.restore()).

Path helpers resolve against process.cwd(). Start the app from its own root.

  • Models: query results are Collections
  • Configuration: data_get-style access via app.config.get()
  • Routing: middleware is a Pipeline
  • Console: the Command base class and Tui in practice
  • Testing: Process.fake(), Tui.fake(), and the rest of the fakes
  • Dates & times: DateTime, Duration, Interval, Period