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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user