Framework-free utilities shared across every app and package here
Zero React, zero Next. Anything touching JSX or a hook lives in the kit instead.
../ui ·
../../docs/app-patterns ·
../../docs/plan/archive/02-toolkit.md
What is this?#
Nineteen small TypeScript modules (dates, text, numbers, urls, results,
sampling, crypto tokens) with no React and no view layer, published as
@versable-git/toolkit 0.1.0 and consumed by nothing yet. They were ported
from enhancement-product/frontend/src/utils/ with four bugs fixed on the way
in. Use one when an app needs exactly that helper; import it by subpath; and
if it is rough, say so on the board, because no consumer has worn these edges
smooth.
| The toolkit | |
|---|---|
| Stack | TypeScript only. No React, no Next, no JSX, by design |
| Ships | 19 modules, 20 subpath exports plus a barrel |
| Tests | vitest, one test file per module, 19 in total |
| Published | To GitHub Packages on a version bump pushed to main (publish-kit workflow); 0.1.0 is the first |
| Consumed by | Nothing yet. Zero imports outside this package |
| Origin | A port, with four known bugs fixed as part of it |
Adoption is zero. Nothing in walmart-mvp, speedway, apps/playground or
packages/ui imports this package today. That is stated plainly rather than left
for someone to discover, because a package existing is not evidence it is the
proven path. If you are the first caller, expect to find rough edges no consumer
has hit yet.
Architecture#
The layering rule is the whole point, and it runs one direction only.
┌──────────────────────────────────────────────────────────┐│ apps walmart-mvp · speedway · apps/playground │└───────────────────────┬──────────────────────────────────┘ │ may import both ┌───────────────┴───────────────┐ ▼ ▼┌────────────────────┐ ┌────────────────────┐│ @versable-git/ui │ ───────▶ │ @versable-git/ ││ React components │ may │ toolkit ││ hooks, JSX │ import │ pure functions │└────────────────────┘ └────────────────────┘ │ ✗ never imports upwardA module here can be used by a server, a worker, an edge runtime or a browser.
The moment something needs a React hook it stops being a toolkit concern and
becomes a kit concern, which is why text.tsx became text.ts on the port and
useEventListener stayed behind.
Use#
import { listToMap } from "@versable-git/toolkit";import { generateCryptoToken } from "@versable-git/toolkit/random.node";import { createUUID } from "@versable-git/toolkit/random.web";Everything is available from the barrel and from its own subpath, with one deliberate exception below.
The runtime-suffix convention#
random.node.ts and random.web.ts are NOT re-exported from the barrel. Import
them directly, as above. This keeps a browser bundle from ever pulling in
node:crypto, and a server bundle from pulling in the web crypto path.
What is in it#
| Module | What it holds | Belongs with | Used by |
|---|---|---|---|
iter | list and collection helpers, listToMap, uniqueKeyVals | not yet mapped | none yet |
object | object shaping and parseSafe | not yet mapped | none yet |
string / text | string predicates, RegexMatch, line splitting | packages/ui/docs/stat-tile.md, /patterns/dashboard-stats (count copy is where the pluralize sites concentrate) | none yet |
number | numeric guards, clampNumber | not yet mapped | none yet |
date | date formatting and comparison, frontend-agnostic | packages/ui/docs/timestamp.md (the same relative-time ladder, solved twice: packages/ui/src/timestamp/timestamp.tsx:17 against src/date.ts:88) | none yet |
url | the QueryParams key registry and the FilterFor factory | not yet mapped | none yet |
file / download | size thresholds, human-readable sizes, blob download | packages/ui/docs/dropzone.md (the blob-download half of the upload and export round trip) | none yet |
random | ids and tokens, plus the two runtime-specific entry points | not yet mapped | none yet |
event | a framework-free event bus | not yet mapped | none yet |
promise | async helpers | not yet mapped | none yet |
result | error shaping, getApiError, throwIfErrorAsync | packages/ui/docs/alert.md, /patterns/confirm-and-report (error-to-display-string is what both surfaces render) | none yet |
validation / security | input checks and sanitisation | not yet mapped | none yet |
sampling | row sampling for previews | not yet mapped | none yet |
types | shared type aliases, none of them framework-coupled | not yet mapped | none yet |
Belongs with names the contract doc or pattern route a module serves, and
each named doc links back here; a pair is written only where a real consumer
or a measured demand exists (docs/plan/62-toolkit-proposals-design.md §4),
so "not yet mapped" is honest rather than missing. Used by is the call site
outside this package, and stays "none yet" until a real call site imports the module.
Helpers that might belong here are filed in PROPOSALS.md.
Known issues fixed on port#
Each has a regression test named after the bug.
- B1
object.ts'sparseSafehad a comma-operator bug that always returned the literal2instead of the parsed JSON value. Fixed; seeobject.test.ts. - B8
number.ts'sclampNumberdefaultedmaxto5000, silently capping any generic numeric value passed through it. Defaultmaxis nowInfinity; callers pass an explicit ceiling when one applies. - B9
file.ts'sFileSizeThresholds.mediumwas100 * Mb(100MB) despite being documented and named as 100KB. Fixed to100 * Kb. - B10
string.ts'sRegexMatch.url/uuid/dateISOonly matched JSON-stringified (double-quoted) input, so testing a raw string, the normal case, always failed silently. The patterns no longer require surrounding quotes.
What was deliberately not ported#
- React/Next coupling dropped wholesale.
text.tsxbecametext.ts: the file no longer contains JSX, so the extension changed too.splitLinesno longer defaults to rendering a<div>per line; it defaults to returning trimmedstring[], with an optionaltransformcallback for a caller that wants to map lines to something else (including JSX, in@versable-git/ui).event.tsports the event bus only;useEventListener(the React hook) belongs inui's hooks.types.tsdropsPageProps/PagePropsV2(ReactNode/React.Attributes),SetState(Dispatch/SetStateAction),SetAtom(Jotai-specific), and the Next-App-RouterIdUrlParam*family, all framework-coupled types with no place in a zero-React package. result.ts'sgetApiErrorno longer imports axios. The old code checkederror instanceof AxiosError; this package detects the same shape structurally (error.isAxiosError === true+error.response.data) instead, so the toolkit does not carry an axios dependency ahead of query-kit's fetch-vs-axios decision (02-toolkit.md§2.1).result.tsdrops the syncthrowIfError. Its mixed sync/async callback handling let a thrown value race past its own.then(); the ledger calls this out as a footgun. Only the async form,throwIfErrorAsync, is ported.url.ts'sQueryParamsregistry is trimmed to generic keys (Tab,Search,Page,Action,SSR,CallbackUrl,Email) plus theFilterForfactory. Keys tied to the old app's domain (JobId,DemoJobType,TemplateId,NewTemplateType,ShowContextColDropdown,ShowPreview,Legacy,Allowed) and theAppActionKeysregistry are dropped. The pattern, a single named registry of query-param keys, is what is worth keeping; each app grows its own keys the same way.QArgs(options for the olduseQueryParams/useQParamhooks) is dropped too, since those hooks are not being ported:qsyncis the one URL-state system going forward.date.tsdropsgetPyDateandgetCurrentDateForDb. Both emulate a Python/Mongo-side date format and belong to the backend boundary, not a frontend-agnostic date util.file.tsandsampling.tslost their project-coupled imports.file.tsno longer imports a human-readable-size helper from an app component; it has its own smallgetHumanReadableFileSize.sampling.tsno longer importsDataRowfrom the app's pipeline types (redefined locally asRecord<string, string | number | boolean | null>) or the Next-specificfaker.client.tswrapper (replaced with a plain dynamicimport("@faker-js/faker/locale/en")).download.ts'sdownloadDataAsFilenow builds aBlobplus object URL instead of adata:URI, removing the old approach's size ceiling and percent-encoding overhead.iter.tskeeps thelodash-es/getdependency. If a consumer needs this module on an edge runtime that cannot bundle lodash, replaceget()inlistToMap/uniqueKeyValswith a hand-rolled dot-path resolver.
Pending decision, flagged rather than resolved#
Two modules (event.ts, promise.ts) read process.env.NODE_ENV for a tiny
internal dev-mode flag. No project-wide env-access convention exists in this
monorepo. Given the footprint, one private unexported one-liner in two files,
this port kept it inline rather than introducing a central config module for a
single boolean. Revisit once a real env-access convention is decided.
Developing#
pnpm typecheck # here, or from the root for every packagepnpm test # vitest, one file per moduleThe regression tests are named after the bugs above, so a failure tells you which ported defect came back rather than only which function broke.
Documentation#
| Where | What it answers |
|---|---|
docs/plan/archive/02-toolkit.md | the original port spec, including the qsync and query-kit plans this package was carved from |
packages/ui/README.md | the layer above, where anything React-shaped belongs instead |
docs/app-patterns/ | how an app is built and documented on top of both packages |
PROPOSALS.md | the backlog of helpers that might belong here: six lines to file, processed as a group |
docs/plan/43-toolkit-rca.md | why nothing consumes this package yet, and the publish step that unblocks it |
Repo Structure#
packages/toolkit/├── src/│ ├── index.ts · the barrel, minus the runtime-suffixed pair│ ├── random.node.ts · node:crypto path, subpath import only│ ├── random.web.ts · web crypto path, subpath import only│ └── *.ts · one module per concern, each with a *.test.ts│├── package.json · 20 subpath exports, publishes on a version bump└── vitest.config.ts · test runner config