Files
welshman/skills/welshman-lib/SKILL.md
T
Jon Staab 48bf9d6ebe
tests / tests (push) Failing after 5m7s
Quote skill descriptions
2026-06-10 14:52:43 -07:00

416 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: welshman-lib
description: "Use this skill when working with @welshman/lib: general-purpose utilities including LRU cache, EventEmitter, Deferred promises, TaskQueue, URL normalization, or other standalone helpers."
---
# welshman/lib — General Utilities
`@welshman/lib` is a lightweight TypeScript utility library that forms the foundation of the welshman nostr stack. It provides common helpers used across all sibling packages: array/object manipulation, numeric helpers, async primitives, caching, event emission, and encoding utilities. It depends on `@scure/base` (for bech32/utf8 encoding) and `events` (Node.js EventEmitter polyfill).
## Installation
```bash
npm install @welshman/lib
# or
pnpm add @welshman/lib
```
## Key Exports
### Deferred Promises
| Export | Description |
|--------|-------------|
| `Deferred<T, E>` | Type: a `Promise<T>` with `.resolve(T)` and `.reject(E)` methods attached |
| `defer<T, E>()` | Creates a `Deferred<T, E>` — a promise with exposed `.resolve()` and `.reject()` |
| `makePromise<T, E>(executor)` | Creates a strongly-typed promise with typed error |
`E` defaults to `T` when omitted. `defer<void>()` for a signal-style deferred. `thunk.complete` in `@welshman/app` is a `Deferred<void>`.
```typescript
import { defer } from '@welshman/lib'
const ready = defer<void>()
socket.on('open', () => ready.resolve())
await ready
```
### EventEmitter
| Export | Description |
|--------|-------------|
| `Emitter` | Extends Node.js `EventEmitter`; all events also fire on the `'*'` listener with the event name prepended |
```typescript
import { Emitter } from '@welshman/lib'
const bus = new Emitter()
bus.on('*', (eventType, ...args) => console.log(eventType, args))
bus.emit('login', { pubkey: '...' })
```
### LRU Cache
| Export | Description |
|--------|-------------|
| `LRUCache<K, V>` | LRU cache; evicts least-recently-used entries when full |
| `cached(options)` | Memoizes a function with an LRU backing cache; exposes `.cache` and `.pop()` |
| `simpleCache(getValue)` | Minimal memoization wrapper with default settings |
```typescript
import { LRUCache, cached } from '@welshman/lib'
const cache = new LRUCache<string, number>(100)
cache.set('x', 42)
cache.get('x') // 42
cache.pop('x') // 42 and removes entry
const getProfile = cached({
maxSize: 500,
getKey: ([pubkey]: [string]) => pubkey,
getValue: ([pubkey]: [string]) => fetchProfile(pubkey),
})
getProfile.pop(pubkey) // invalidate one entry
```
### Task Queue
| Export | Description |
|--------|-------------|
| `TaskQueue<Item>` | Processes items asynchronously in configurable batches |
Options: `batchSize`, `batchDelay` (ms), `processItem`. Methods: `.push(item)`, `.remove(item)`, `.start()`, `.stop()`, `.clear()`, `.process()`, `.subscribe(cb)`.
```typescript
import { TaskQueue } from '@welshman/lib'
const queue = new TaskQueue<string>({
batchSize: 10,
batchDelay: 0,
processItem: async (item) => { /* handle */ },
})
queue.start()
queue.push('task-1')
```
### URL Normalization
| Export | Description |
|--------|-------------|
| `normalizeUrl(url, options?)` | Normalizes a URL string (ported from sindresorhus/normalize-url) |
| `stripProtocol(url)` | Removes the protocol prefix (`http://`, `wss://`, etc.) |
| `displayUrl(url)` | Strips protocol, `www.`, and trailing slash for display |
| `displayDomain(url)` | Extracts just the domain from a URL |
```typescript
import { normalizeUrl, displayUrl, displayDomain } from '@welshman/lib'
normalizeUrl('sindresorhus.com/about.html#contact', { stripHash: true })
// => 'http://sindresorhus.com/about.html'
displayUrl('https://www.example.com/path/') // => 'example.com/path'
displayDomain('relay.damus.io/path') // => 'relay.damus.io'
```
> **Note:** `normalizeUrl` defaults to `http://` protocol. Pass `{ defaultProtocol: 'https' }` if needed.
### Async Utilities
| Export | Description |
|--------|-------------|
| `sleep(ms)` | Returns a promise that resolves after `ms` milliseconds |
| `yieldThread()` | Yields to the event loop (microtask break) |
| `poll(options)` | Polls until a condition is met or an AbortSignal fires; options: `{ signal, condition, interval? }` |
| `throttle(ms, fn)` | Returns a throttled version of `fn` |
| `throttleWithValue(ms, fn)` | Throttled function that returns the cached return value between updates |
| `batch(t, fn)` | First call fires `fn([item])` immediately; subsequent calls within `t` ms are collected and `fn` is called with all accumulated items |
| `batcher(t, execute)` | Collects calls for `t` ms, then calls `execute` with all accumulated requests; each individual call returns a `Promise<U>` resolved with its result from the batch. Unlike `batch`, the first call is also deferred — nothing fires immediately. |
| `race(threshold, promises)` | Resolves when `threshold` fraction of promises complete |
### Timestamp / Time Constants
| Export | Description |
|--------|-------------|
| `MINUTE`, `HOUR`, `DAY`, `WEEK`, `MONTH`, `QUARTER`, `YEAR` | Duration constants **in seconds** |
| `LOCALE` | User's default locale string |
| `TIMEZONE` | User's timezone offset string (e.g. `+05:30`) |
| `now()` | Current Unix timestamp in seconds |
| `ago(unit, count?)` | Unix timestamp from `count` units ago — e.g. `ago(DAY, 7)` |
| `int(unit, count?)` | Multiplies a time unit by count — e.g. `int(HOUR, 2)` = 7200 |
| `ms(seconds)` | Converts seconds to milliseconds |
| `secondsToDate(ts)` / `dateToSeconds(date)` | Convert between Unix seconds and `Date` |
| `createLocalDate(dateString, timezone?)` | Parses a date string as a local date in the given timezone |
| `formatTimestamp(ts)` | Formats Unix seconds as a short datetime string |
| `formatTimestampAsDate(ts)` | Formats Unix seconds as a long date string |
| `formatTimestampAsTime(ts)` | Formats Unix seconds as a time string |
| `formatTimestampRelative(ts)` | Formats Unix seconds as "x minutes ago" |
> **Note:** All time constants are in **seconds**, not milliseconds. Use `ms(n)` to convert for `setTimeout`.
### Number Utilities
| Export | Description |
|--------|-------------|
| `ensureNumber(x)` | `parseFloat(x)` — accepts `string \| number` |
| `num(x)` | Returns `x \|\| 0` — converts `undefined` to 0 |
| `add(x, y)` / `sub(x, y)` / `mul(x, y)` / `div(x, y)` | Arithmetic with `undefined`-safe operands |
| `inc(x)` / `dec(x)` | Increment / decrement (undefined-safe) |
| `lt(x, y)` / `lte(x, y)` / `gt(x, y)` / `gte(x, y)` | Comparisons (undefined-safe) |
| `max(xs)` / `min(xs)` / `sum(xs)` / `avg(xs)` | Aggregates over `(number \| undefined)[]` |
| `between([low, high], n)` | `n > low && n < high` (exclusive) |
| `within([low, high], n)` | `n >= low && n <= high` (inclusive) |
| `clamp([min, max], n)` | Constrains `n` to the range |
| `round(precision, x)` | Rounds to `precision` decimal places |
### Array / Sequence Utilities
All return new arrays — no mutation.
| Export | Description |
|--------|-------------|
| `first(xs)` / `last(xs)` | First/last element (`undefined` if empty) |
| `ffirst(xs)` | First element of the first iterable in a nested iterable |
| `take(n, xs)` / `drop(n, xs)` | Slice from start / drop from start |
| `concat(...xs)` | Flattens vararg arrays into one, skipping any argument that is `undefined` |
| `append(x, xs)` / `prepend(x, xs)` | Add element to end / start |
| `remove(x, xs)` | Remove all occurrences of `x` |
| `removeAt(i, xs)` | Remove element at index `i` |
| `splitAt(n, xs)` | Split into `[xs.slice(0, n), xs.slice(n)]` |
| `insertAt(n, x, xs)` | Insert `x` at index `n` |
| `replaceAt(n, x, xs)` | Replace element at index `n` with `x` |
| `uniq(xs)` / `uniqBy(f, xs)` | Deduplicate |
| `sort(xs)` | Sorted copy (natural order) |
| `sortBy(f, xs)` | Sort by key function |
| `groupBy(f, xs)` | Returns `Map<K, T[]>` |
| `indexBy(f, xs)` | Returns `Map<K, T>` (last item wins per key) |
| `countBy(f, xs)` | Returns `Map<K, number>` |
| `partition(f, xs)` | Split into `[passing, failing]` |
| `chunk(n, xs)` | Split into fixed-size chunks of length `n` |
| `chunks(n, xs)` | Split into exactly `n` chunks |
| `toggle(x, xs)` | Add if absent, remove if present (pure) |
| `union(a, b)` / `intersection(a, b)` / `difference(a, b)` / `without(a, b)` | Set operations |
| `sample(n, xs)` / `shuffle(xs)` / `choice(xs)` | Random selection / shuffle / single random pick |
| `flatten(xs)` | Flatten one level |
| `ensurePlural(x)` | Wraps a value in `[x]` if it isn't already an array |
| `removeUndefined(xs)` | Filters out `undefined` values |
| `overlappingPairs(xs)` | Returns `[[xs[0],xs[1]], [xs[1],xs[2]], ...]` |
| `range(a, b, step?)` | Generator yielding numbers from `a` to `b` (exclusive) |
| `enumerate(xs)` | Generator yielding `[index, item]` tuples |
| `pluck<T>(k, xs)` | Maps `xs` to `xs[k]` |
| `fromPairs(pairs)` | Creates an object from `[key, value]` tuples |
| `initArray(n, f)` | Creates an array of length `n` using generator `f` |
| `isIterable(x)` / `toIterable(x)` | Check / wrap as iterable |
| `map(f, xs)` / `filter(f, xs)` / `reject(f, xs)` | Iterable-safe versions (accept any `Iterable<T>`) |
| `find(f, xs)` / `some(f, xs)` | Iterable-safe find / any-match |
### Object Utilities
| Export | Description |
|--------|-------------|
| `isPojo(obj)` | Returns `true` if value is a plain object (not class instance, null, or array) |
| `pick(keys, obj)` / `omit(keys, obj)` | Include / exclude keys |
| `omitVals(vals, obj)` | Remove entries whose value is in `vals` |
| `filterVals(f, obj)` | Keep entries where `f(value)` is truthy |
| `mapKeys(f, obj)` / `mapVals(f, obj)` | Transform keys or values |
| `mergeLeft(a, b)` / `mergeRight(a, b)` | Shallow merge — left/right wins on conflicts |
| `deepMergeLeft(a, b)` / `deepMergeRight(a, b)` | Deep merge — left/right wins on conflicts |
| `switcher(key, map)` | Lookup with implicit `map.default` fallback |
| `mapPop(k, m)` | Gets and deletes key from a `Map<K, T>` — returns `T \| undefined` |
> **Note:** `mergeLeft(a, b)` means `a` wins — it spreads `b` first, then `a` on top.
### TypeScript Utility Types
```typescript
import type { Override, MakeOptional, MakeNonOptional, Obj, Maybe, MaybeAsync } from '@welshman/lib'
type UserWithRole = Override<User, { role: 'admin' | 'user' }>
type DraftUser = MakeOptional<User, 'id' | 'createdAt'>
type FullUser = MakeNonOptional<User>
type AnyRecord = Obj // Record<string, any>
type MaybeStr = Maybe<string> // string | undefined
```
### Functional / Combinator Helpers
| Export | Description |
|--------|-------------|
| `noop` | No-op function |
| `identity(x)` | Returns `x` unchanged |
| `always(x)` | Returns a function that always returns `x` |
| `not(x)` | Logical NOT |
| `complement(f)` | Returns `(...args) => !f(...args)` |
| `tap(f)` | Returns `(x) => { f(x); return x }` — runs a side effect and passes the value through |
| `bind(f, ...args)` | Partially applies `f` with leading `args` |
| `equals(a, b)` | Deep equality (handles arrays, Sets, plain objects) |
| `tryCatch(f, onError?)` | Calls `f`, swallows errors, returns `undefined` on failure |
| `thrower(message)` | Returns a function that throws `new Error(message)` when called |
| `once(f)` | Wraps `f` so it only executes once |
| `memoize(f)` | Single-slot memoization: caches last call; re-runs when args change |
| `call(f)` | Calls `f()` immediately — IIFE alternative; useful with async |
| `ifLet(x, f)` | Calls `f(x)` only if `x` is defined |
| `doLet(x, f)` | Calls `f(x)` and returns the result — scoped binding without a variable |
| `isDefined(x)` / `isUndefined(x)` / `assertDefined(x)` | `undefined` checks (not null) |
### Curried Collection Helpers
Useful as `.filter()` / `.map()` callbacks:
| Export | Description |
|--------|-------------|
| `eq(v)` / `ne(v)` | `x => x === v` / `x => x !== v` |
| `prop(k)` | `x => x[k]` — pluck a property |
| `propIn(k, xs)` | `x => xs.includes(x[k])` — property is in list |
| `nth(i)` | `xs => xs[i]` — element at index |
| `nthEq(i, v)` | `xs => xs[i] === v` |
| `nthNe(i, v)` | `xs => xs[i] !== v` |
| `nthIn(i, vs)` | `xs => vs.includes(xs[i])` |
| `nthNotIn(i, vs)` | `xs => !vs.includes(xs[i])` |
| `spec(values)` | `x => all key-value pairs in values match x` |
| `member(xs)` | `x => xs.includes(x)` |
| `assoc(k, v)` | `obj => ({ ...obj, [k]: v })` — add/update property |
| `dissoc(k)` | `obj => omit([k], obj)` — remove property |
```typescript
import { eq, prop, nth, nthEq, nthIn, nthNotIn, spec, member, assoc, dissoc } from '@welshman/lib'
events.filter(spec({ kind: 1 })) // kind === 1
events.map(prop('id')) // pluck id
tags.filter(nthEq(0, 'p')) // tags where tag[0] === 'p'
tags.filter(nthIn(0, ['p', 'e'])) // tags where tag[0] is 'p' or 'e'
tags.filter(nthNotIn(0, ['p', 'e'])) // tags where tag[0] is neither
items.filter(member(['a', 'b'])) // items in the set
items.map(assoc('seen', true)) // add property
items.map(dissoc('secret')) // remove property
```
### Bech32 / Hex / Binary Encoding
| Export | Description |
|--------|-------------|
| `hexToBech32(prefix, hex)` | Encodes hex string to bech32 (e.g. `npub`, `note`) |
| `bech32ToHex(b32)` | Decodes bech32 to hex |
| `bytesToHex(buffer)` | `ArrayBuffer \| Uint8Array` to hex string |
| `hexToBytes(hex)` | Hex string to `Uint8Array` |
| `sha256(data)` | SHA-256 hash of binary data — async, returns hex string |
| `textEncoder` | Shared `TextEncoder` instance |
| `textDecoder` | Shared `TextDecoder` instance |
### JSON / Storage / Network
| Export | Description |
|--------|-------------|
| `parseJson(str)` | Safe `JSON.parse` — returns `undefined` on error or empty input |
| `getJson(key)` / `setJson(key, val)` | `localStorage` get/set with JSON serialization |
| `fetchJson(url, opts?)` | Fetch JSON with optional method/headers/body |
| `postJson(url, data, opts?)` | POST JSON to a URL |
| `uploadFile(url, file)` | Upload a `File` object via `multipart/form-data` POST |
| `on(target, event, cb)` | Type-safe `.on()` wrapper — returns an unsubscribe `() => void` |
### Randomness / IDs
| Export | Description |
|--------|-------------|
| `randomId()` | Generates a random string ID |
| `randomInt(min?, max?)` | Random integer in range (inclusive; default 09) |
### String Utilities
| Export | Description |
|--------|-------------|
| `ellipsize(s, len, suffix?)` | Truncates at word boundary with an ellipsis suffix (default `"..."`) |
| `displayList(xs, conj?, n?)` | Oxford-comma list — e.g. `"a, b, and c"` |
| `hash(s)` | Numeric hash from a string |
### Map / Set Helpers
```typescript
import { addToKey, pushToKey, addToMapKey, pushToMapKey } from '@welshman/lib'
// Object-keyed (Record<string, Set<T>> / Record<string, T[]>)
addToKey(byTag, 'p', pubkey) // adds to Set at key 'p'
pushToKey(byKind, '1', eventId) // appends to array at key '1'
// Map-keyed (Map<K, Set<T>> / Map<K, T[]>)
addToMapKey(m, relay, eventId)
pushToMapKey(m, relay, eventId)
```
## Common Patterns
### Pattern 1 — Batching writes (IndexedDB / relay)
`batch(t, f)` — first call fires immediately; subsequent calls within `t` ms are accumulated and flushed together.
```typescript
import { batch, on } from '@welshman/lib'
import type { RepositoryUpdate } from '@welshman/net'
on(
repository,
'update',
batch(3000, async (updates: RepositoryUpdate[]) => {
const toAdd = updates.flatMap(u => u.added)
const toRemove = new Set(updates.flatMap(u => u.removed))
const tx = db.transaction('events', 'readwrite')
await Promise.all([
...toAdd.map(e => tx.store.put(e)),
...Array.from(toRemove).map(id => tx.store.delete(id)),
tx.done,
])
}),
)
```
### Pattern 2 — Grouping and indexing nostr events
```typescript
import { groupBy, indexBy, sortBy } from '@welshman/lib'
const byKind = groupBy((e) => e.kind, events) // Map<number, Event[]>
const byId = indexBy((e) => e.id, events) // Map<string, Event>
const sorted = sortBy((e) => -e.created_at, events) // newest first
```
### Pattern 3 — Relative timestamp display
```typescript
import { now, ago, DAY, formatTimestampRelative } from '@welshman/lib'
const recentEvents = events.filter(e => e.created_at > ago(DAY, 7))
const label = formatTimestampRelative(event.created_at) // "3 hours ago"
```
### Pattern 4 — Subscribing to EventEmitter-based objects with `on`
`on(target, event, handler)` returns an unsubscribe function:
```typescript
import { on } from '@welshman/lib'
import { repository } from '@welshman/app'
const unsub = on(repository, 'update', updates => {
console.log('added', updates.flatMap(u => u.added).length, 'events')
})
// Later:
unsub()
```
### Pattern 5 — IIFE alternative with `call`
```typescript
import { call } from '@welshman/lib'
call(async () => {
const data = await fetchJson('/something')
})
```
## Integration Notes
- **All welshman packages depend on `@welshman/lib`** — `Deferred`, `Emitter`, time constants, and collection utilities are shared across `@welshman/net`, `@welshman/store`, `@welshman/util`, etc.
- **`@welshman/net`** uses `Emitter` (via `Tracker`, `Repository`, `WrapManager`), `batch`, and `LRUCache` internally. `Socket` extends Node's built-in `EventEmitter` directly.
- **`@welshman/app`** thunks use `Deferred<void>``thunk.complete` resolves when all relays have responded or timed out.
- **`batcher`** is used in `@welshman/net` for deduplicating concurrent fetch requests — pass it an `execute` function that returns results in the same order as its inputs.