feat(util): create @uncaged/workflow-util package

Extract pure utility functions from workflow/src/util/ into standalone package.
Types (Result, ok, err) now come from @uncaged/workflow-protocol.

Contains: base32 encoding, ULID generation, structured logger,
storage-root helpers, refs-field normalization.

Ref: #143, closes #145
This commit is contained in:
2026-05-09 11:08:04 +08:00
parent 2cffaad127
commit bf0bc47a3f
9 changed files with 240 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@uncaged/workflow-util",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
+80
View File
@@ -0,0 +1,80 @@
import { err, ok, type Result } from "@uncaged/workflow-protocol";
/** Crockford Base32 alphabet (no I, L, O, U) — exactly 32 symbols. */
export const CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const DECODE_MAP: Record<string, number> = (() => {
const map: Record<string, number> = {};
for (let i = 0; i < CROCKFORD_BASE32_ALPHABET.length; i++) {
map[CROCKFORD_BASE32_ALPHABET[i]] = i;
}
return map;
})();
function padBitCount(bitLength: number): number {
const r = bitLength % 5;
return r === 0 ? 0 : 5 - r;
}
/**
* Encode an integer using exactly `bitLength` significant bits, MSB-first,
* with the minimum number of leading zero bits so the total is a multiple of 5.
*/
export function encodeCrockfordBase32Bits(value: bigint, bitLength: number): string {
if (bitLength <= 0) {
throw new Error("bitLength must be positive");
}
const padBits = padBitCount(bitLength);
const totalBits = bitLength + padBits;
const charCount = totalBits / 5;
const shifted = value << BigInt(padBits);
let result = "";
for (let i = 0; i < charCount; i++) {
const shift = totalBits - 5 * (i + 1);
const quintet = Number((shifted >> BigInt(shift)) & 31n);
result += CROCKFORD_BASE32_ALPHABET[quintet];
}
return result;
}
export function decodeCrockfordBase32Bits(
encoded: string,
bitLength: number,
): Result<bigint, Error> {
if (bitLength <= 0) {
return err(new Error("bitLength must be positive"));
}
const padBits = padBitCount(bitLength);
const totalBits = encoded.length * 5;
if (totalBits !== bitLength + padBits) {
return err(new Error("encoded length does not match bitLength"));
}
let shifted = 0n;
for (let i = 0; i < encoded.length; i++) {
const ch = encoded[i];
if (ch === undefined) {
return err(new Error("invalid encoded string"));
}
const upper = ch.toUpperCase();
const val = DECODE_MAP[upper];
if (val === undefined) {
return err(new Error(`invalid Crockford Base32 character: ${ch}`));
}
shifted = (shifted << 5n) | BigInt(val & 31);
}
return ok(shifted >> BigInt(padBits));
}
/** XXH64-sized value (13 Crockford chars). */
export function encodeUint64AsCrockford(value: bigint): string {
const masked = value & 0xffff_ffff_ffff_ffffn;
return encodeCrockfordBase32Bits(masked, 64);
}
export function decodeCrockfordToUint64(encoded: string): Result<bigint, Error> {
const decoded = decodeCrockfordBase32Bits(encoded, 64);
if (!decoded.ok) {
return decoded;
}
return ok(decoded.value & 0xffff_ffff_ffff_ffffn);
}
+13
View File
@@ -0,0 +1,13 @@
export {
CROCKFORD_BASE32_ALPHABET,
decodeCrockfordBase32Bits,
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "./base32.js";
export { createLogger } from "./logger.js";
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
export { ok, err } from "@uncaged/workflow-protocol";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
export type { CreateLoggerOptions, LogFn, LoggerSink, Result } from "./types.js";
export { generateUlid } from "./ulid.js";
+50
View File
@@ -0,0 +1,50 @@
import { appendFileSync } from "node:fs";
import { CROCKFORD_BASE32_ALPHABET } from "./base32.js";
import type { CreateLoggerOptions, LogFn } from "./types.js";
const TAG_LENGTH = 8;
const TAG_CHAR_SET: ReadonlySet<string> = new Set(CROCKFORD_BASE32_ALPHABET.split(""));
function assertValidLogTag(tag: string): void {
if (tag.length !== TAG_LENGTH) {
throw new Error(`log tag must be exactly ${TAG_LENGTH} characters`);
}
for (let i = 0; i < tag.length; i++) {
const ch = tag[i];
if (ch === undefined) {
throw new Error("log tag validation failed");
}
const upper = ch.toUpperCase();
if (!TAG_CHAR_SET.has(upper)) {
throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`);
}
}
}
/** Append one JSONL log record: `{ tag, content, timestamp }` per RFC-001. */
export function createLogger(options: CreateLoggerOptions): LogFn {
if (options.sink.kind === "stderr") {
return (tag: string, content: string) => {
assertValidLogTag(tag);
const line = `${JSON.stringify({
tag: tag.toUpperCase(),
content,
timestamp: Date.now(),
})}\n`;
process.stderr.write(line);
};
}
const filePath = options.sink.path;
return (tag: string, content: string) => {
assertValidLogTag(tag);
const line = `${JSON.stringify({
tag: tag.toUpperCase(),
content,
timestamp: Date.now(),
})}\n`;
appendFileSync(filePath, line, "utf8");
};
}
+22
View File
@@ -0,0 +1,22 @@
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
const out = [...refs];
if (!out.includes(contentHash)) {
out.push(contentHash);
}
return out;
}
/** Normalize `refs` from persisted JSONL or IPC payloads (missing or invalid → []). */
export function normalizeRefsField(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const out: string[] = [];
for (const x of value) {
if (typeof x === "string") {
out.push(x);
}
}
return out;
}
@@ -0,0 +1,13 @@
import { homedir } from "node:os";
import { join } from "node:path";
/** Default filesystem root for workflow data (`~/.uncaged/workflow`). */
export function getDefaultWorkflowStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
}
/** Global content-addressed store directory under the workflow storage root (`<root>/cas`). */
export function getGlobalCasDir(storageRoot: string | undefined): string {
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
return join(root, "cas");
}
+9
View File
@@ -0,0 +1,9 @@
export type { Result } from "@uncaged/workflow-protocol";
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
export type CreateLoggerOptions = {
sink: LoggerSink;
};
export type LogFn = (tag: string, content: string) => void;
+28
View File
@@ -0,0 +1,28 @@
import { encodeCrockfordBase32Bits } from "./base32.js";
const ULID_TIME_BITS = 48;
const ULID_RANDOM_BITS = 80;
function readRandomUint80(): bigint {
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
let x = 0n;
for (let i = 0; i < bytes.length; i++) {
x = (x << 8n) | BigInt(bytes[i]);
}
return x & ((1n << 80n) - 1n);
}
/**
* Generate a ULID using Crockford Base32: 10 timestamp chars + 16 random chars.
* Timestamp uses 48 bits of Unix time in milliseconds.
*/
export function generateUlid(nowMs: number): string {
if (!Number.isFinite(nowMs) || nowMs < 0 || nowMs >= 2 ** ULID_TIME_BITS) {
throw new Error("nowMs must be a finite number in [0, 2^48)");
}
const time = BigInt(Math.floor(nowMs));
const rand = readRandomUint80();
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}