diff --git a/.changeset/fn-orpc-initial.md b/.changeset/fn-orpc-initial.md new file mode 100644 index 0000000..23221a9 --- /dev/null +++ b/.changeset/fn-orpc-initial.md @@ -0,0 +1,5 @@ +--- +"@davstack/fn-orpc": minor +--- + +Initial release: oRPC transport adapter for @davstack/fn — turn directly-callable fns into oRPC procedures via `initProcedureFactory`. diff --git a/.changeset/fn-trpc-initial.md b/.changeset/fn-trpc-initial.md new file mode 100644 index 0000000..e7e8012 --- /dev/null +++ b/.changeset/fn-trpc-initial.md @@ -0,0 +1,5 @@ +--- +"@davstack/fn-trpc": minor +--- + +Initial release: tRPC adapter for `@davstack/fn` (`initProcedureFactory`), extracted from the core package. diff --git a/.changeset/fn-v2-transport-agnostic.md b/.changeset/fn-v2-transport-agnostic.md new file mode 100644 index 0000000..40002ff --- /dev/null +++ b/.changeset/fn-v2-transport-agnostic.md @@ -0,0 +1,16 @@ +--- +"@davstack/fn": major +--- + +**Breaking — core is now transport-agnostic with zero runtime dependencies.** + +- **Removed `.safeCall`.** Wrap a direct call with `tryCatch` from the new + `@davstack/try-catch` package to get a `{ data, error }` result instead of a throw: + `const { data, error } = await tryCatch(() => myFn({ input, ctx }))`. +- **Removed the `Result` type** (it now lives in `@davstack/try-catch`). +- **Extracted the tRPC adapter (`initProcedureFactory`) to the new `@davstack/fn-trpc` + package.** Install `@davstack/fn-trpc` and import `initProcedureFactory` from there. +- **Removed the `pipe` export** (dead code inherited from the old `store` package; the + middleware array is strictly more capable). +- **Dropped `@trpc/server` and `superjson` from dependencies** (`superjson` was unused; + tRPC moved to `@davstack/fn-trpc`). diff --git a/.changeset/try-catch-initial.md b/.changeset/try-catch-initial.md new file mode 100644 index 0000000..1e721b0 --- /dev/null +++ b/.changeset/try-catch-initial.md @@ -0,0 +1,5 @@ +--- +"@davstack/try-catch": minor +--- + +Initial release: `tryCatch(promiseOrThunk)` returns a `{ data, error }` result instead of throwing. Zero dependencies. diff --git a/.davstack/logs/default.db b/.davstack/logs/default.db new file mode 100644 index 0000000..159e90a Binary files /dev/null and b/.davstack/logs/default.db differ diff --git a/.davstack/logs/default.db-shm b/.davstack/logs/default.db-shm new file mode 100644 index 0000000..9689d0e Binary files /dev/null and b/.davstack/logs/default.db-shm differ diff --git a/.davstack/logs/default.db-wal b/.davstack/logs/default.db-wal new file mode 100644 index 0000000..43d3c3e Binary files /dev/null and b/.davstack/logs/default.db-wal differ diff --git a/.gitignore b/.gitignore index 4a30cf2..61319f8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,8 @@ server/dist public/dist .turbo coverage -/temp \ No newline at end of file +.folder-meta.generated.md +.folder-peek.generated.md +.davstack/evals/ +/temp +paper.md diff --git a/feedback/explore-compact-vs-file.md b/feedback/explore-compact-vs-file.md new file mode 100644 index 0000000..bc15212 --- /dev/null +++ b/feedback/explore-compact-vs-file.md @@ -0,0 +1,90 @@ +Reflections from running `explore` BOTH ways on the *same* task in one session, so this is a +controlled A/B, not two unrelated jobs. Task: "produce a completion / gap-map for the +enum-contains workstream — what's done vs outstanding vs superseded, judged against the actual +code." I ran `--file ` and `--compact-mode "map remaining enum-contains work against what +we implemented"` back-to-back against an identical repo state, then diffed the two deliverables. +Verdict up front: **for this task `--file` was the better output, but the reason is instructive +and the two are genuinely complementary — neither alone was complete.** + +## What each produced + +- **`--file`** (scoped spec; I hand-wrote goal + the one supersession gotcha + the + committed-vs-uncommitted facts + explicit path scope): ~480-line gap-map grouped by workstream, + dual-sided evidence (the doc that states a goal AND the code path:line that does/doesn't satisfy + it), explicit DONE/PARTIAL/OUTSTANDING/SUPERSEDED labels, plus a supersession table. +- **`--compact-mode`** (5-word inline, distilled the spec from the live transcript): one tight + status matrix + thematic evidence blocks. Leaner, faster to read. + +## The deciding difference: coverage mirrors the context source + +This is the real finding, and it predicts when to use which: + +- **`--file` covers what you point it at.** I scoped it to read `constraints/` source, so it found + the highest-value item in the whole audit: an existing unit test + (`test_substring_operator_rejected_when_allowed_values_present[contains]`) that the proposed + change **breaks** — a concrete ship blocker. I verified it; it does fail. Nothing in our + conversation had mentioned that test, so it could only be found by *systematically reading the + code*, which is exactly what a path-scoped spec forces. + +- **`--compact-mode` covers what the conversation knows.** It distilled the transcript, so it was + excellent at surfacing a *tension already latent in the discussion*: it independently flagged the + n=25-vs-r10 contradiction (baseline VSL 25/25→72 in one doc, 9/10→188 in another) as + "reconcile". That's a synthesis catch — it connected two things we'd touched. But it **missed the + breaking test**, because that test was never in the transcript. + +So the blind spots are symmetric and predictable: compact can't catch what wasn't discussed; file +can't catch what you didn't scope. For a **completion/ship audit** — where unknown blockers and +systematic coverage are the whole point — `--file` wins. For a **"where do we stand / what's +inconsistent"** read, compact wins and at ~10× lower authoring effort (5 words vs a 40-line spec). + +## What compact got for free that file made me re-type + +Worth calling out for the SKILL.md guidance: the spec gotcha I most cared about — "later docs +supersede earlier ones, here's the implementation reality" — compact inherited *for free* from the +transcript. For `--file` I had to hand-write all of it into the spec, and if I'd forgotten the +supersession note the file agent would have reported stale doc TODOs as open work. So the existing +guidance ("prefer compact when the conversation already has the context") is right and earns its +keep — compact is lower-effort AND less error-prone *for the context it can see*. The catch is only +coverage of things outside the transcript. + +## Recommendations for the skill + +1. **Document the coverage-vs-context tradeoff explicitly in SKILL.md.** Current guidance frames + compact-vs-file as "is the context already in the conversation?" That's necessary but not + sufficient. Add: *compact's coverage is bounded by the transcript — it will not find blockers in + code that wasn't discussed. For completeness/audit/"did we miss anything" tasks, use `--file` + with explicit code scope, or run both.* + +2. **Offer a hybrid: `--compact-mode --also-scan `.** The ideal for this task was compact's + free transcript context PLUS a forced systematic read of named files. Today I had to choose. A + compact run that ALSO guarantees coverage of explicitly-named paths would have caught both the + contradiction and the breaking test in one job. + +3. **Running both is a cheap power-move — make it a documented pattern.** Two jobs on one task cost + little and the outputs covered each other's blind spots (file found the blocker, compact found + the contradiction). A one-liner in SKILL.md ("for high-stakes audits, fan out one compact + one + scoped file and diff them") would surface this. + +4. **For the deliverable shape: `--file`'s dual-sided evidence (doc-claim path:line + code-reality + path:line, side by side) is the more verifiable format** and should be the default the + spec-writer aims for, because it lets the caller re-derive each verdict in one click instead of + trusting a synthesis sentence. + +## Tool friction hit along the way (secondary, but worth fixing) + +- **The installed `.claude/skills/explore` copy was stale** — it pointed at an old + `bun .../scripts/explore.ts` CLI that rejected my inline `` with "shell-hostile chars" and + swept "stray 0-byte .test.ts litter". The real entrypoint is the global `explore` bin from + `@davstack/open-agents`. `npx explore` also failed ("could not determine executable"). A user + following SKILL.md's `npx explore submit` verbatim hits a dead end; the working invocation + (global bin, or pnpm-linked local) isn't obvious. Worth a one-line "if `npx explore` fails, the + bin is `@davstack/open-agents` — install/link it" note. +- **Inline single-fact form `'.. ..'` is fragile** — the `<>?()` chars + trip a shell-hostility guard, so the documented single-fact path effectively forces you to + compact-mode or `--file` anyway. Either escape-handle it or drop the inline-tags form from the + docs. + +Net: both modes are good; the choice is "transcript-synthesis (compact, cheap)" vs +"systematic-coverage-of-scoped-code (file, thorough)", and for anything where *missing something is +the failure mode*, file — or both. The single most valuable upgrade would be letting compact-mode +also take explicit `--also-scan` paths so you stop having to choose. diff --git a/notes/davstack-peek/1-initial-concept.md b/notes/davstack-peek/1-initial-concept.md new file mode 100644 index 0000000..a2ac4be --- /dev/null +++ b/notes/davstack-peek/1-initial-concept.md @@ -0,0 +1,90 @@ +# Davstack Peek Initial Concept + +Idea: add a small package/CLI, `@davstack/peek`, that prints concise +folder-level peek files for agents. + +## Motivation + +Agents often need a quick map of a folder before deciding what to read. Full +recursive exploration can burn tokens and time. A generated metadata file could +act like a compact table of contents for source and docs. + +## Possible CLI + +```text +npx @davstack/peek ./path-to-folder +npx @davstack/peek ./path-to-folder --agent +npx @davstack/peek ./path-to-folder --human +``` + +## `gen` + +`gen` scans a folder and writes a concise generated markdown file in that +folder, likely something like `.folder-peek.generated.md`. + +Initial scope: + +- Include markdown, TypeScript, and Python files. +- Skip files ignored by git. +- Skip generated peek files themselves. +- Prefer concise structural summaries over content summaries. + +For markdown: + +- Include file path. +- Include headings. + +For TypeScript: + +- Include exported functions, classes, types, interfaces, consts, and important + non-exported top-level symbols. +- Possibly include JSDoc in a longer mode. + +For Python: + +- Include functions, classes, constants, and module docstring where useful. + +## Peek + +The CLI prints existing peek output for a folder. If the peek file does not +exist, it can generate it on the spot. + +`--deep` could recursively include child folder output, either by generating +missing child files or by doing a direct recursive scan. + +## Output Shape + +Experiment with XML-ish blocks because agents parse them well: + +```xml + + + + + + + + +``` + +There may be two useful output levels: + +- Short: names and headings only. +- Long: names, signatures, JSDoc/docstrings, and maybe one-line comments. + +## Git Hygiene + +Generated peek files should probably be gitignored by default. The package +could also provide a recommended `.gitignore` entry. + +## Agent Workflow + +An agent can run: + +```text +npx @davstack/peek packages/open-agents/src --deep +``` + +Then use the output as a cheap routing map before opening source files. This +could pair well with open-agent handoff testing because history/context pointers +are more useful when agents can cheaply inspect repo structure too. diff --git a/notes/multi-db/01-plan.md b/notes/multi-db/01-plan.md new file mode 100644 index 0000000..ae0dba0 --- /dev/null +++ b/notes/multi-db/01-plan.md @@ -0,0 +1,355 @@ +# Multi-DB log routing — implementation plan + +**Status**: design locked, ready to file as issue + start Phase 1. +**Target version**: `@davstack/logs-server@2.0.0` for Phase 1 (breaking: default path move). +**Related**: closes / supersedes the design portion of #51 (session scoping). Builds on #52 (`logs_v` view, shipped in 1.4.0). + +## Motivation + +Today every log emitted by a project lands in `.davstack/logs.db`. Concurrent debug sessions (or just multiple bugs worked in the same repo) pile their logs into one file. Symptoms: + +- Probe-tag greps find hits from earlier unrelated sessions ("cross-session noise" — the user-facing problem in #51). +- The cognitive cost of `WHERE ts > :baseline` scoping every query. +- Eval runs that should keep their log output co-located with their test artifacts can't — logs go to a fixed global location. + +Each of these collapses if logs from a given context land in their own DB file. The DB file becomes the session boundary. + +A second benefit, equally load-bearing: **per-DB views**. With per-session DBs, agents and humans can `CREATE VIEW probe AS …` in the session DB with a probe vocabulary tailored to THAT bug — and cleanup is `rm` the file. This is the "temp views with auto-cleanup" idea, achieved by making the DB itself the session boundary instead of trying to scope views within a shared DB. + +## Out of scope (v1) + +**Cross-service routing.** When `--db=reorder-bug` is set, only logs from the frontend (which reads the value at boot) land in `reorder-bug.db`. Backend logs continue to route to `default.db`. The user's stack today is frontend-emission-only, so this isn't a felt loss. If/when cross-stack tracing becomes a concrete pain, design a v2.X to add it — likely either an explicit `X-Davstack-Db` header that backends propagate, or a daemon-side `trace_id → db` correlation cache. Both were considered for v1 and deferred: the header approach asks meaningful work of every backend service; the correlation cache is implicit-state magic that's hard to debug when it misbehaves. Wait for real cases. + +**Listing / cleanup tooling.** `ls .davstack/logs/` + `rm` are fine. No `logs-server db list / drop` verbs in v1. Revisit if friction shows up. + +**Sentry integration sub-package.** Not shipping `@davstack/logs-server/sentry`. The consumer wiring is small (3 lines in their `sentry.ts`); a package wrapper adds peer-dep / version-compat maintenance for marginal ergonomic gain. Document the snippet instead. + +## What we're building (high level) + +``` +[browser tab w/ frontend transmitter] + │ Sentry envelope POST (existing DSN, no transport changes) + │ each log envelope carries attributes['davstack-logs.db'].value if set + ↓ +[logs-server daemon @ :5181] + │ reads attributes['davstack-logs.db'].value from each log + │ validates + resolves to a path + │ getOrOpenDb() → cached Database handle + │ strips the routing attribute from data before persisting + │ first open: CREATE TABLE + idx + logs_v view (existing openDb) + ↓ +[.davstack/logs/.db] or escaped to e.g. /my-evals/run-1/logs.db +``` + +Persisted `data` is byte-for-byte the envelope's log record **minus the one daemon-managed routing key**. The DB filename IS the session indicator; nothing inside the row records which bucket it landed in. + +## File layout (post-2.0) + +``` +/ + .davstack/ + logs/ + default.db ← un-tagged emissions (no attribute set) + reorder-bug.db ← runs with --db=reorder-bug + hotfix-7c.db + column-sync.db + config/ ← existing, untouched + logs-server.config.ts +``` + +Plus, when the `..` escape is used for eval co-location: + +``` +/ + my-evals/ + runs/ + 1779889134/ + test-results.json + logs.db ← --db=../../my-evals/runs/1779889134/logs + .davstack/ + logs/ + default.db ← still here for un-tagged work +``` + +## Wire shape: a Sentry log attribute + +**Routing channel**: the Sentry log envelope attribute `davstack-logs.db` (value = the `--db` string). + +Why an attribute, not a header / URL param / DSN tweak: +- The consumer (in their `sentry.ts`) already stamps attributes inside `beforeSendLog` for `diag.project` / `diag.run_id`. Adding one more line is the minimum-friction surface for them — no transport rewrite, no DSN change, no Sentry-SDK-internal customization. +- Existing CORS / proxy / Sentry-SDK behavior unchanged — the wire shape is identical to what's already crossing it. +- Value is an opaque string in the consumer's transmitter — `..` for the eval escape case just rides through as plain text. No URL encoding, no path-normalization surprises. + +The daemon does its own validation + resolution server-side; the consumer never needs to know the file-path semantics. + +Why `davstack-logs.db` and not `diag.db`: `diag.*` is a name-holdover from when the package was `diag` / `log-sink`. New attribute keys should use the current `@davstack/logs-server` identity. Existing `diag.project` / `diag.run_id` stay untouched (real consumers depend on those names). + +## `--db` value: validation pipeline + +The daemon receives one string. Pipeline: + +1. **Per-segment charset**: split on `/`, each segment must match `^[a-z0-9][a-z0-9_-]*$` (lowercase alnum, hyphen, underscore; no leading separator char). Segments `.` and `..` are allowed as path-traversal tokens (validated in step 4, not 1). +2. **No absolute paths**: reject if value starts with `/` or matches `[A-Za-z]:` (Windows drive prefix). +3. **Resolve to absolute**: `path.resolve(repoRoot, '.davstack/logs', value)`. `repoRoot` discovered by walking up for `pnpm-workspace.yaml` / `turbo.json` / `package.json#workspaces` / `.git`, capped at 8 levels — the same walker `loadConfig` already uses. +4. **Containment check**: resolved path MUST start with `/`. Anything resolving above `repoRoot` is rejected as "escapes repo." +5. **Auto-append `.db`** if value doesn't already end in `.db`. +6. **On reject**: log a single-line stderr warning (`[logs-server] invalid db "": ; routing to default.db`) and dispatch to `default.db` rather than failing. Warn-once per unique invalid value to avoid spam. + +### Examples + +| Input | Resolves to | Status | +|---|---|---| +| `reorder-bug` | `.davstack/logs/reorder-bug.db` | ✓ | +| `feat/reorder` | `.davstack/logs/feat/reorder.db` | ✓ subdirs ok | +| `../sandbox/x` | `.davstack/sandbox/x.db` | ✓ in repo | +| `../../my-evals/run-1/logs` | `/my-evals/run-1/logs.db` | ✓ in repo | +| `../../../escaped` | `/escaped.db` | ✗ escapes repo | +| `/etc/passwd` | absolute | ✗ | +| `C:\evil` | abs (win) | ✗ | +| `Reorder-Bug` | uppercase | ✗ | +| `reorder bug` | space | ✗ | +| `reorder-bug.db` | `.davstack/logs/reorder-bug.db` | ✓ (.db pre-supplied) | + +## Daemon implementation + +**In `envelope.ts` (or wherever the parser lives)**: after parsing each log item, extract the routing attribute and strip it from the persisted record. + +```ts +// pseudocode in toRow() +const routeKey = "davstack-logs.db" +const dbName = rec.attributes?.[routeKey]?.value as string | undefined +if (rec.attributes && routeKey in rec.attributes) { + delete rec.attributes[routeKey] // strip before JSON.stringify(rec) +} +return { + ..., + data: JSON.stringify(rec), + _route_db: dbName, // internal-only field, drives dispatch, not persisted +} +``` + +**In `ingest.ts`**: group rows by `_route_db`, dispatch each group to the appropriate `Database` handle. + +```ts +// pseudocode +const groups = new Map() +for (const r of stamped) { + const k = r._route_db + if (!groups.has(k)) groups.set(k, []) + groups.get(k)!.push(r) +} +let accepted = 0 +for (const [rawDbName, batch] of groups) { + const resolved = rawDbName ? resolveDbPath(rawDbName, ctx.repoRoot) : ctx.defaultDbPath + const db = ctx.handleCache.getOrOpen(resolved) + accepted += insertLogs(db, batch) +} +``` + +**In `db.ts`** (or a new `db-cache.ts`): a `DbHandleCache` mapping `absolutePath → Database`. + +```ts +// pseudocode +class DbHandleCache { + private handles = new Map() + private idleCloseMs = 30 * 60 * 1000 // 30 min + + getOrOpen(path: string): Database { + const cached = this.handles.get(path) + if (cached) { + cached.lastUsed = Date.now() + return cached.db + } + ensureParent(path) + const db = openDb(path) // existing — runs CREATE TABLE + idx + logs_v view + this.handles.set(path, { db, lastUsed: Date.now() }) + return db + } + + startIdleSweeper() { + setInterval(() => { + const cutoff = Date.now() - this.idleCloseMs + for (const [p, entry] of this.handles) { + if (entry.lastUsed < cutoff) { + entry.db.close() + this.handles.delete(p) + } + } + }, 5 * 60 * 1000).unref() + } +} +``` + +Schema-boot happens automatically via existing `openDb()` — every new DB file gets `CREATE TABLE logs`, the correlation index, and the `logs_v` view (from #52). No special-case code. + +**Default DB path**: `path.resolve(repoRoot, '.davstack/logs/default.db')`. Used when the attribute is absent or invalid. + +## Emission surfaces — how the value reaches the consumer + +### Browser (the only surface in v1) + +The runner sets a global before the app boots. The app reads it at `initSentry()` time and stamps it onto every log via the existing `beforeSendLog`. + +**Runner side (`playwright-server --db=`):** + +```ts +// pseudocode in playwright-server's spec-runner +if (cliFlags.db) { + await page.addInitScript((dbName) => { + ;(window as any).__davstack_db = dbName + }, cliFlags.db) +} +await page.goto(spec.baseUrl) +``` + +`addInitScript` runs before any page script — so by the time the app's bundle starts and `initSentry()` runs, `window.__davstack_db` is already populated. + +**Consumer side (in their `sentry.ts`):** + +Three lines added next to the existing `diagRunId` setup: + +```ts +// near the top, after diagRunId is captured +const davstackDb = (window as { __davstack_db?: string }).__davstack_db ?? null + +// inside the existing beforeSendLog, alongside diag.project / diag.run_id +log.attributes = { + ...log.attributes, + "diag.project": DIAG_PROJECT, + "diag.run_id": diagRunId, + ...(davstackDb && { "davstack-logs.db": davstackDb }), +} +``` + +No transport rewrite. No DSN change. No URL param. No integration package import. Existing Sentry init unchanged otherwise. + +### Node (deferred — not in v1) + +Currently no node-side emitters in the user's stack. The same shape would apply if added later: `vitest-server --db=` sets `process.env.DAVSTACK_DB` on the spawned child; the test process reads it at boot and stamps the attribute in its own `beforeSendLog`. No daemon changes needed — the wire is identical. + +## Phased landing + +### Phase 1 — daemon-side dispatch (logs-server 2.0.0) + +Self-contained, ships without touching any runner or transmitter. + +**Scope:** +- `envelope.ts`: extract `davstack-logs.db` from attributes, strip from `rec` before persist, surface as internal `_route_db` field +- `ingest.ts`: group-by-`_route_db` dispatch loop +- `db.ts`: `DbHandleCache`, `resolveDbPath()`, validation +- `openDb()`: unchanged (already creates the table + index + `logs_v` view; runs per DB) +- Default path moves: `.davstack/logs.db` → `.davstack/logs/default.db` +- `prune` verb: walks `.davstack/logs/*.db` instead of single file (`--db ` to scope to one if needed) +- Docs updates (see Docs phase below) +- Tests: dispatch routing, validation table cases, escape rules, cache idle-close, strip-before-persist verification +- CHANGELOG `2.0.0` block with migration note + +**BC notes**: requests without the attribute (i.e. everyone today) route to `default.db` in the new location. The only breaking change is the path move — anyone with hardcoded `sqlite3 .davstack/logs.db` scripts updates to `sqlite3 .davstack/logs/default.db`. The `logs-server check` verb should detect a legacy `.davstack/logs.db` file at boot and print a one-line migration hint. + +**Not in this phase**: no runner flags, no transmitter changes, no node-side stamping. Phase 1 is a pure daemon upgrade. Until Phase 2/3 land, nobody is setting the attribute, so behavior is identical to today except the file moves into `logs/`. + +### Phase 2 — `playwright-server --db` flag + +One package, minor bump (likely `@davstack/playwright-server@1.4.0`): + +- Add `--db=` CLI flag +- In the spec-runner, if flag is set, call `page.addInitScript` to seed `window.__davstack_db` before navigation +- Test: assert `window.__davstack_db` is set in the page context when the flag is passed + +~5–10 lines + a test. + +### Phase 3 — consumer wiring snippet + +Not a davstack ship — a docs deliverable. Add the 3-line snippet to `packages/logs-server/docs/transmitter-wiring.md` (see Docs phase). The user copies it into their app's `sentry.ts`. + +## Docs phase (lands with Phase 1) + +### Updates to existing docs + +- **`packages/logs-server/docs/reading-logs.md`**: revise opening to mention the per-DB layout. Replace `.davstack/logs.db` with `.davstack/logs/default.db` throughout. Add a "Choosing a DB" subsection explaining file-per-session, naming convention, opening the right one (`sqlite3 .davstack/logs/.db`). +- **`packages/logs-server/docs/setup.md`**: document the new default path. Note `pruneDays` semantics (per-DB or all-DBs, whichever we land on). +- **`packages/logs-server/docs/writing-logs.md`**: add a short "Routing logs to a specific DB" section pointing to the new `transmitter-wiring.md` for the snippet. +- **`packages/logs-server/README.md`**: one-line update to reflect the new default path in any inline example. +- **`packages/logs-server/CHANGELOG.md`**: 2.0.0 entry with migration note. +- **`~/dev/davstack/skills/*`**: audit any skill docs that reference the old `.davstack/logs.db` path or teach query patterns. + +### New doc: `packages/logs-server/docs/transmitter-wiring.md` + +How to wire your app's `sentry.ts` to honor `window.__davstack_db`. The single ~3-line snippet shown above + brief explanation of what each line does. Includes a one-paragraph note that backend logs (today) go to default DB, by design. + +### New doc: `packages/logs-server/docs/session-views.md` + +**The per-DB views guide.** This is the second load-bearing reason to ship multi-DB — each session DB is an isolated playground for session-specific views, with cleanup-via-`rm`. + +Content outline: + +1. **Why per-DB views are now safe** + - With one global DB, `CREATE VIEW probe AS …` would pollute every future query. + - With per-session DBs, the view lives in THAT bug's DB. When you `rm .davstack/logs/reorder-bug.db`, the views go with it. + - This achieves the "temp view with auto-cleanup" goal without SQLite's per-connection limitation. + +2. **The pattern** + - Pick a DB: `sqlite3 .davstack/logs/reorder-bug.db` + - Define a probe-vocabulary view at the start of the session: + ```sql + CREATE VIEW probe AS + SELECT id, ts, level, + json_extract(data, '$.body') AS msg, + json_extract(attrs, '$.seam') AS seam, + json_extract(attrs, '$.nextDims') AS nextDims, + json_extract(attrs, '$.prevDims') AS prevDims, + json_extract(attrs, '$.columnOrder') AS columnOrder + FROM logs_v + WHERE msg LIKE '%[colorder-probe]%'; + ``` + - Every subsequent query becomes `SELECT … FROM probe …` — one cheap reference, no JSON-extract repetition. + +3. **Useful view shapes** + - `pre_fix` / `post_fix`: filter by `ts <` / `>=` a fix-application timestamp; compare side-by-side. + - `this_run`: `WHERE run_id = 'r-XYZ'` — scope to one run within the session. + - `errors_only`: convenience filter for an errors-with-context recipe. + - Pivot views when probes emit a fixed attribute set: rotate `attrs.` into typed columns for `WHERE` ergonomics. + +4. **Lifecycle** + - Views persist across `sqlite3` invocations within the same DB file. + - Cleanup = delete the file: `rm .davstack/logs/.db`. + - To drop just one view in a live session: `DROP VIEW probe;`. + +5. **Agent usage** + - If an agent is iterating in a session, the FIRST query of the iteration is `CREATE VIEW … IF NOT EXISTS` for the probe vocabulary; subsequent queries hit `FROM probe`. This is the agent-side hygiene equivalent of "stash the projection once, reuse it everywhere." + - Naming convention for agent-created views: prefix `dbg_` so they're easy to spot and drop in bulk (`SELECT name FROM sqlite_master WHERE type='view' AND name LIKE 'dbg_%'`). + +6. **Anti-patterns** + - Don't `CREATE VIEW` in `default.db` (or any sessionless DB). That view is now global noise. + - Don't rely on `CREATE TEMP VIEW` — it's per-connection and evaporates between sqlite invocations. + +## Open items / things to confirm before coding + +These have been discussed and tentatively decided, but worth a final pass before Phase 1 starts: + +1. **Charset rule**: `[a-z0-9_-]+` per segment (lowercase alnum + hyphen + underscore). Tentative. Hyphen + underscore both allowed; user said "might want an underscore at some point." +2. **Validation reject behavior**: warn-on-stderr + route to default (friendly). Not fail-loud. Tentative — could be revisited if it masks typos in practice. +3. **Idle-close timeout**: 30 min. Pick by feel; can tune. Memory cost of holding DB handles open is trivial; only reason to close is to free file descriptors. +4. **What if the legacy `.davstack/logs.db` file exists at boot?** Tentative answer: print a warning, create the new default anyway, leave the legacy file orphaned. The `logs-server check` verb gets a row that flags the legacy file's presence with the migration command (`mv .davstack/logs.db .davstack/logs/default.db`). +5. **`prune` verb scoping**: walk all DB files by default, or require `--db `? Default to "all" seems closer to today's "one DB pruned" behavior — semantically the same global cleanup. + +## Test plan (Phase 1) + +Unit: +- `resolveDbPath`: full validation table from above, every row a test case. +- `DbHandleCache`: cache hit, miss, idle-close after configured timeout, parallel insert into same DB doesn't collide (WAL handles it). +- Containment check: `..`, double-`..`, absolute paths, Windows drive prefixes — all rejected per the table. +- Strip-before-persist: insert a log with `davstack-logs.db` attribute, assert the persisted `data` blob does NOT contain that key in its `attributes`. + +Integration: +- Two `POST /envelope/` requests carrying different `davstack-logs.db` attributes → two DB files materialize with correct row counts. +- Attribute-absent request → row in `default.db`. +- Invalid value → stderr warning + row in `default.db`. +- Backend service emits a log (no attribute) → row in `default.db` (regression test for "we didn't accidentally make backend logs disappear"). + +E2E (after Phase 2): +- `playwright-server --db=test-bug e2e/smoke.spec.ts` against a sample app that includes the transmitter snippet → rows land in `.davstack/logs/test-bug.db`, not `default.db`. + +## Filing + +Open a single issue (`feat(logs-server): per-DB log routing via davstack-logs.db attribute`) referencing this plan as the canonical design. Mark with `breaking-change` label given the 2.0 bump. Cross-link to #51 (closes if appropriate) and #52 (depends-on, already shipped). diff --git a/notes/multi-db/02-per-run-routing-and-consumer-overhead.md b/notes/multi-db/02-per-run-routing-and-consumer-overhead.md new file mode 100644 index 0000000..3029d6f --- /dev/null +++ b/notes/multi-db/02-per-run-routing-and-consumer-overhead.md @@ -0,0 +1,185 @@ +# Per-run log-DB routing: header experiment (works) → the cleaner trace_id + integration design + +**Date:** 2026-06-03 +**Status:** Header approach PROTOTYPED + VALIDATED in traffease, then REVERTED. Recommended design (sink-side trace_id inference + Sentry integration packaging) NOT yet built. Pick up tomorrow. +**Repos:** experiment ran in `~/dev/traffease_man` (branch `feat/full-stack-logs-infra`, PR #1229); real implementation home is `~/dev/davstack` (`@davstack/logs-server`). +**Continues:** [`01-plan.md`](./01-plan.md) +**⚠️ SUPERSEDED (2026-06-03):** the per-run **file-routing** direction below (Approach A/B/C) is +replaced by [`03-clean-and-label-the-stateless-pivot.md`](./03-clean-and-label-the-stateless-pivot.md) +— a single DB + a logical `label` (filter at query time) + a `clean` verb. The trace-propagation +**foundation** here still holds (it's reused at query time); the **routing machinery** is demoted to +an optional escape hatch. Read 03 first. + +--- + +## 0. TL;DR + +- **Problem:** today the server tiers (Node, Python backend, Python agent) pick their sink DB from a **boot-time `LOGS_DB` env**, so to route a run into its own `.davstack/logs/.db` you must set `LOGS_DB` on `docker compose up`. That pins the whole container to one DB → **can't run parallel runs through shared containers** (the actual goal). +- **We proved** a per-request `x-davstack-db` **header** approach works end-to-end (logs *and* spans route per-request, no per-container env). See §2 for the validated result. +- **But we're not shipping it:** it demands ~5 edit sites × every service in every consumer repo, and **structurally can't do WebSocket** (browsers can't set WS headers → the agent chat path is excluded). Wrong place for the complexity given we maintain the package and want **minimal consumer setup** (traffease + titanium + public sharing). +- **Recommended instead (§3):** **sink-side `trace_id → db` inference.** `trace_id` is *already* propagated across all tiers by Sentry for free; the DB is decided once at the trace root (browser tab = the run). So don't propagate the DB at all — let the **sink** learn `trace_id → db` from the browser's already-stamped rows and route every same-trace row accordingly. **Zero server-tier consumer code. Solves WebSocket. Backward compatible.** +- **Plus (§4):** package the *stamping* boilerplate (project / run_id / db-hint `before_send` hooks, ~100 lines per service today) as a **Sentry integration** `davstackLogs({ project })`. Routing stays sink-side; stamping shrinks to one init line. +- **Open knobs (§6):** back-fill migration vs. buffering vs. accept-leak for pre-mapping rows; where the integration package lives. + +--- + +## 1. Context / how routing works today + +- The sink dispatches **per-record**: every log/span envelope may carry a `davstack-logs.db` attribute; the sink writes that row to `.davstack/logs/.db` (validated + sandboxed in `db-route.ts`), strips the key, else → `default.db`. Code path: `ingest.ts` → `stamp()` → `resolveTarget(routeDb, ctx)` → `resolveRoutedDb()`. `db.ts` already has a `trace_id` column (indexed) and `selectByTrace()`. +- **Who stamps the routing key today:** + - **Browser (React):** from `window.__davstack_db` (set pre-page-load by the e2e fixture / devtools). → **already per-run.** `react/src/config/sentry.ts` `beforeSendLog` + `beforeSendTransaction`. + - **Node / backend / agent:** from **`env.LOGS_DB`** (boot-time constant) in their `beforeSend(Transaction|Log)` hooks. → **per-container.** +- **Backend already has more machinery than the others:** `backend/src/config/diag_logs.py` has a `_logs_db` **ContextVar** that *wins over* the env (`resolve_logs_db()` precedence: ContextVar > `LOGS_DB` env > None), plus a `logs_db()` context manager (used by the eval `--db` flag) and `ROUTE_DB_ATTR = "davstack-logs.db"`. Agent + Node only read env. + +So routing is *already per-record* — the only reason it's "per-container" is that 3 tiers stamp a **constant**. Make them stamp a **per-run** value and parallelism falls out. The header experiment does that via the request; the recommended design does it via the trace. + +--- + +## 2. What we tried: per-request `x-davstack-db` header (Approach A) — WORKS, but reverted + +### Mechanism +Browser stamps `x-davstack-db: ` on every outgoing HTTP request; each server reads it per-request and routes that request's logs+spans to `.db`. React calls all three backends **directly** (star topology, not a chain) — `VITE_BACKEND_URL`=node, `VITE_AGENT_API_ROOT`=agent, `VITE_AGENTFLOW_API_URL`=backend — so mostly the browser stamps + each server reads; server↔server propagation is a smaller secondary concern. + +### The one non-obvious technical win (keep this knowledge) +**Backend middleware must be pure-ASGI, not `BaseHTTPMiddleware`.** Sentry's `before_send_transaction` fires at **transaction-finish**, which is *after* a `BaseHTTPMiddleware.dispatch` returns (Starlette runs the app in a child task → the ContextVar binding is gone by the time the txn is serialized → **span routing silently lost**, though *logs* would still route since they're emitted mid-request). A **pure-ASGI** middleware sets the ContextVar in the *same* async context Sentry finishes the transaction in → both logs AND spans route. **This was the main risk and it's now empirically dead** (see below). + +### Validated result (real, this session) +- Backend restarted with `ENABLE_LOGS=1`, **no `LOGS_DB`**. Fired 5 requests with `x-davstack-db: hdrtest1` + 3 without. +- `hdrtest1.db` got exactly **5 logs + 5 spans, project `traffease-backend`**; the 3 header-less control requests did **not** leak in. → per-request routing works for both logs and spans, with zero env. +- Earlier in the session we also confirmed (compose-tagged run) that one `trace_id` spans react+node+backend, and that **server rows carry the browser's `trace_id` even with no routing code** — this is the foundation Approach C relies on (§3). +- **Cross-stack re-validation (a separate Next.js app [titanium]):** independently confirmed the same foundation on a different stack/topology by querying that app's local sink — found **344 distinct `trace_id`s shared between confirmed browser rows and server rows**. Browser rows were positively identified by client-only console output (React DevTools notice / Fast Refresh / hydration warnings); server rows by a server-only `server.address` attribute. So browser→server `trace_id` propagation — the thing Approach C stands on — is **not stack-specific**: it holds on Next.js (Sentry's server SDK continues the browser's trace via the `sentry-trace`/`baggage` headers) where the topology is browser → middleware/edge → RSC/route-handlers, not traffease's star. (Two caveats found there, now resolved — see 03 §8: browser shipped *logs but not spans* — a **config gap**, fixed by enabling `browserTracingIntegration` on the client, not a limitation; and trace grain is **per-pageload/navigation** — fine for run/session/`db`/`label` keying, too coarse for per-test.) + +### Why we reverted it (the adoption argument) +- **Consumer surface is the worst of all options:** per service you write inbound-read + scope-binding + outbound-propagation + (Node) a CORS allowlist entry; the browser needs an HTTP-client interceptor. ~5 sites × N services, replicated in every adopting repo. +- **WebSocket is structurally excluded:** browsers can't set custom headers on `WebSocket`. The agent chat path is raw WS → never covered by a header scheme. +- We maintain `@davstack/logs-server` and want **minimal consumer setup**. Header plumbing is the opposite. + +### Exact diff we reverted (recoverable — re-apply if ever needed) + +> 5 files, +90/−6. All gated so prod is byte-identical (`diag_logs_enabled()` / `logsEnabled`). Reverted via `git restore` on branch `feat/full-stack-logs-infra` (these were uncommitted working-tree edits on top of the committed PR). + +```diff +# backend/src/config/diag_logs.py — pure-ASGI middleware + header const ++ROUTE_DB_HEADER = b"x-davstack-db" ++ ++class LogsDbRoutingMiddleware: ++ """Pure-ASGI (NOT BaseHTTPMiddleware): ContextVar set survives to ++ before_send_transaction at txn-finish, so SPAN routing works.""" ++ def __init__(self, app): self.app = app ++ async def __call__(self, scope, receive, send): ++ if scope.get("type") in ("http", "websocket"): ++ for key, value in scope.get("headers") or []: ++ if key == ROUTE_DB_HEADER: ++ db = value.decode(errors="ignore").strip() ++ if db: _logs_db.set(db) ++ break ++ await self.app(scope, receive, send) + +# backend/src/app/middleware.py — wire it in, gated ++def configure_logs_db_routing(app): ++ if diag_logs_enabled(): ++ app.add_middleware(LogsDbRoutingMiddleware) +# ...called in configure_middleware() between event_context and exception_handlers + +# react/src/lib/axios/axios.ts — forward the header from window.__davstack_db (all 3 Orval clients funnel through addAuthHeaders) ++const addDavstackDbHeader = (config) => { ++ const db = (window as Window & { __davstack_db?: string }).__davstack_db ++ if (db) config.headers["x-davstack-db"] = db ++} +# ...addDavstackDbHeader(config) appended inside addAuthHeaders() + +# node/src/server.ts — CORS allow + scope-bind middleware +-allowedHeaders: ["Content-Type", "Authorization", "sentry-trace", "baggage"] ++allowedHeaders: ["Content-Type", "Authorization", "sentry-trace", "baggage", "x-davstack-db"] ++app.use((req, _res, next) => { ++ const db = req.headers["x-davstack-db"] ++ if (typeof db === "string" && db) Sentry.getCurrentScope().setTag("davstack-logs.db", db) ++ next() ++}) + +# node/src/config/sentry.ts — per-request tag wins over env in stampDiagOnTransaction ++const perRequestDb = tx.tags?.["davstack-logs.db"] ++const logsDb = (typeof perRequestDb === "string" && perRequestDb) || env.LOGS_DB ++if (tx.tags) delete tx.tags["davstack-logs.db"] +``` + +> Agent tier was **not** implemented (would need the ContextVar machinery the backend has + a FastAPI middleware + the unsolved WS path). Deliberately stopped before it once the strategy pivoted. + +--- + +## 3. Recommended: sink-side `trace_id → db` inference (Approach C) + +### The insight +Sentry **already propagates `trace_id`** across every hop, free, zero consumer code (it's the cross-service-join key that already works). The DB is chosen **once at the trace root** (the browser tab = one run) and is constant for the whole trace. So **don't propagate the DB name at all** — associate `trace_id → db` once and let the sink route. + +### Mechanism (all in `@davstack/logs-server`) +1. Browser stamps `davstack-logs.db` on **its own** rows — **already happens** (from `window.__davstack_db`). +2. Sink keeps an in-memory `Map`. On ingest, if a row carries both `trace_id=T` and `davstack-logs.db=D` → learn `T → D`. +3. Any row (from *any* tier) with `trace_id=T` and **no** explicit route → route to `D` via the map. +4. Rows that already landed in `default.db` before the mapping was learned → **back-fill** (`INSERT…SELECT` into `D.db` + `DELETE` from default) when `T → D` is first seen. + +Slot-in point: `resolveTarget(routeDb, ctx)` in `ingest.ts` (consult the map when `routeDb` is absent); learning + back-fill hang off the same ingest loop. `db.ts` already has `selectByTrace()` for the migration query. + +### Why this is the right call +- **Server-tier consumer code: ZERO.** No middleware, no CORS, no header, no `before_send` routing. They emit normal trace-correlated envelopes (already do). +- **Solves WebSocket / agent for free:** the agent's spans already carry the bridged `trace_id` (`agent_traces.sentry_trace_id` in the PR) → they route correctly with no agent code. The thing the header approach *couldn't* do. +- **Backward compatible & additive:** explicit `davstack-logs.db` (env-based `LOGS_DB`, pytest `--db`, browser stamp) still works and takes precedence; inference only fills the gap. +- **Complexity lives once, in the package we own** — not copied into every adopter forever. + +### Cost / risks +- **Sink becomes stateful** (the map + back-fill). Bound it: TTL / LRU on the map (dev-only, fine). +- **Ordering race:** server rows for `T` can arrive before the browser's `D`-stamped row. Browser usually starts the trace first, but server spans flush at request-end. Back-fill self-heals it; the design knob (§6) is whether to back-fill, buffer briefly, or accept a small leak to `default.db`. +- A trace mapping to two DBs "shouldn't happen" (DB is per-tab-run) — assert/last-writer-wins. + +--- + +## 4. The other half: a `davstackLogs()` Sentry integration (packaging the stamping) + +Routing is only half the consumer pain. The *other* half is that today **every service hand-writes ~100 lines** of `before_send_log` / `before_send_transaction` to stamp `diag.project`, `diag.run_id`, and the db hint (see `react/src/config/sentry.ts`, `node/src/config/sentry.ts`, `backend|agent/src/config/sentry.py` — all near-duplicates). + +Package that as a **Sentry integration** per platform (browser / node / python): + +```ts +// target consumer surface — per service, this is the WHOLE setup: +Sentry.init({ + dsn: davstackSinkDsn(), // helper: dev → local sink, prod → real DSN + integrations: [davstackLogs({ project: "myapp-web" })], +}) +``` + +What the integration does (event processors, not consumer code): +- stamp `diag.project` (from the one config arg) on logs + span data, +- stamp `diag.run_id` (browser: per-page-load uuid + `window.__diagRunId`), +- browser only: stamp the `davstack-logs.db` hint from `window.__davstack_db` — **this is the single feeder for Approach C.** + +**Keep routing OUT of the integration.** An integration can't cleanly propagate a custom key across services (that's Approach B / baggage — see §5, fragile). Routing belongs sink-side (§3). The integration only owns *stamping*. + +Net consumer overhead after C + integration: **routing → 0 lines; stamping → 1 integration line per service; DSN → 1 helper call.** Down from ~100 lines × N services + per-container env. + +--- + +## 5. Approach B (Sentry baggage) — considered, parked + +Put the db in Sentry **baggage** (W3C baggage / DSC): Sentry auto-propagates `baggage` outbound+inbound, and CORS already allows it (the PR added it for tracing). DSC is frozen at trace root — which actually *fits* (db is constant per trace). + +**Why parked, not chosen:** relies on Sentry propagating **third-party (non-`sentry-`) baggage items** end-to-end, which is SDK-version-dependent and underdocumented across the three SDKs we use (browser/node/python, mixed v8/v9). You'd still need an integration to inject at the root + read downstream + stamp. More moving parts and more fragility than §3 for the *same* outcome. **Fallback only** if trace-inference (§3) proves too racy in practice. + +--- + +## 6. Decisions / open questions for tomorrow + +1. **Build order:** (a) prototype C in `~/dev/davstack` and re-validate the parallel-e2e with zero server config, then (b) the `davstackLogs()` integration. Probably C first (unblocks the real goal). +2. **Pre-mapping rows knob:** back-fill migration (self-healing, more code) **vs.** brief buffering window **vs.** accept-leak-to-default (simplest). Recommend back-fill; it's bounded and gives correct files. +3. **Integration packaging:** new package(s) vs. part of the existing logs-server client surface? Three runtimes (browser/node/python) — python can't live in an npm package, so at least a `davstack-logs` PyPI shim is implied. Scope this. +4. **`davstackSinkDsn()` helper:** ship per-platform helpers so the DSN line is also boilerplate-free. +5. **titanium check:** validate the target surface against titanium's setup so we don't design only for traffease. +6. **Map bound:** TTL/LRU sizing for `trace_id → db` (dev volumes are small; pick something boring). + +--- + +## 7. State left behind (for clean resume) + +- **Code:** the §2 diff was **reverted** (`git restore` on the 5 files). Branch `feat/full-stack-logs-infra` is back to its committed state. The browser's existing `davstack-logs.db` stamping is **untouched** (it's the feeder for C). +- **Containers:** `node` / `backend` / `agent` left running with `ENABLE_LOGS=1`, **no `LOGS_DB`** (so → `default.db`). To fully restore the pre-session state, recreate without `ENABLE_LOGS`. +- **Sink test DBs created this session** (`hdrtest1.db`, `qbtrace.db`, `bug-5.db`) can be deleted from `traffease_man/.davstack/logs/`. +- **Untracked in traffease:** `PR-1229-how-to-use.md` (the rewritten "How to use it" section for PR #1229 — separate, unrelated workstream). diff --git a/notes/multi-db/03-clean-and-label-the-stateless-pivot.md b/notes/multi-db/03-clean-and-label-the-stateless-pivot.md new file mode 100644 index 0000000..9e89830 --- /dev/null +++ b/notes/multi-db/03-clean-and-label-the-stateless-pivot.md @@ -0,0 +1,286 @@ +# The stateless pivot: drop per-file routing → `label` (filter) + `clean` (size) + +**Date:** 2026-06-03 +**Status:** DESIGN AGREED, not yet built. Supersedes the routing direction in +[`02-per-run-routing-and-consumer-overhead.md`](./02-per-run-routing-and-consumer-overhead.md) +(Approaches A/B/C). The empirical foundation from 02 (browser→server `trace_id` +propagation, incl. the cross-stack Next.js re-validation) **still holds and is reused** — +just at query time instead of ingest time. +**Repo:** `~/dev/davstack` (`@davstack/logs-server`). + +--- + +## 0. TL;DR + +- **Decision:** abandon **physical per-run DB-file routing** (02's Approach A/B/C). It was + meant to *simplify* the workflow but did the opposite. Replace it with two small, + **stateless, local, independent** primitives: + 1. **`label`** — a free-form annotation stamped **once at the source** (browser), used to + **filter at query time** via a `trace_id` self-join. Zero server-tier code. + 2. **`clean`** — a CLI verb + auto-clean loop that bounds the working DB's size + (archive-or-delete old rows, then vacuum). +- **The core insight:** physical routing forced the sink to decide *which file* a row goes to + **at ingest** — before the browser's stamp may have arrived. *That* is the single source of + all of 02's complexity (stateful `trace_id→db` map, back-fill, ordering race). Drop routing + and the decision moves to **query time**, where the data is already complete → **the sink + goes back to being dumb.** +- **`label` and `clean` are complementary, not redundant.** `label` = *logical* isolation + (keep history, look back at past runs, compare — without deleting). `clean` = *physical* + hygiene (a true slate when you want one; bounded disk). Having both means you **don't have to + clean on every run** to stay un-confused. +- **Consumer surface:** deliberately minimal — server tiers stamp **nothing** (joined in by + trace); only the browser stamps `label`. Packaging that into an integration + DSN helper is + **deferred** (see §6) — not building a custom abstraction now. + +--- + +## 1. Why we're pivoting (the complexity audit) + +Per-run **file** routing (`.davstack/logs/.db`) sounded like clean isolation. In +practice it dragged in (all documented in 02 §3/§6): + +- the sink must become **stateful** — an in-memory `Map`; +- it needs **back-fill** — moving rows that landed in `default.db` before the mapping was + learned; +- it has an **ordering race** — server spans flush at request-end, often *before* the + browser's db-stamped row arrives. + +Every one of those exists for a single reason: **a physical file must be chosen the instant a +row is ingested**, and at that instant the routing value may not have been seen yet. + +A **metadata tag** has none of that pressure. It just rides on the row; the sink stores it and +moves on (it already stores arbitrary `attrs`). You decide what to *do* with it — filter, +group — **later, at query time, when every row is already present.** No map, no back-fill, no +race. The sink stays stateless. + +So: routing-to-simplify backfired. Tag-and-filter is both simpler *for the user* and *far* +less machinery *in the package*. The test we'll hold every piece of this design to: +**does it keep the sink stateless?** If a feature reintroduces the map/back-fill/race, it's +the wrong feature. + +--- + +## 2. The `label` primitive + +### 2.1 Name +Call it **`label`**. Not `tag` — the `logs` table already has a `tag` column for **row-type** +tags (`server-fn`, `test-boundary`, `action`); overloading it will confuse every query. Not +`run` — collides with the existing `run_id` (process/page-load uuid). `label` is unambiguous: +*a free-form annotation the human chose for this run.* + +### 2.2 How it's set & propagated (one place) +Identical mechanism to the browser's existing `davstack-logs.db` stamp — which already works: + +1. Source sets it: e.g. `LABEL=login-bug pnpm playwright …` → the e2e fixture reads + `process.env.LABEL` and sets `window.__davstack_label` (via `addInitScript`), exactly like + `window.__davstack_db` today. For manual browsing, set it from devtools or leave it absent. +2. The browser's `beforeSendLog` / integration stamps `attrs.label` on **its own** rows. +3. **That's it.** Server tiers stamp nothing. They are correlated in by `trace_id` (validated: + server rows carry the browser's trace — 02 §2 + the Next.js cross-stack re-validation). + +### 2.3 How it's queried (the whole "routing" replacement) +Resolution is a query-time self-join on the validated key: + +```sql +-- everything belonging to a labelled run, across all tiers +SELECT l.* +FROM logs l +WHERE l.trace_id IN ( + SELECT DISTINCT trace_id + FROM logs + WHERE json_extract(attrs, '$.label') = :label + AND trace_id IS NOT NULL AND trace_id != '' +); +``` + +A CLI verb (`logs --label `) wraps this; optionally a `labelled_logs` VIEW. **The sink does +nothing special for labels** — it only needs the value present on ≥1 row per trace (the browser +row). Server rows stay lean; they're pulled in by the join. Storage cost: the label is +denormalised onto browser rows only (a small fraction of rows). + +### 2.4 Why keep `label` *and* `clean` +- `label` lets you **retain and revisit** past runs and still tell them apart — so you're not + forced to `clean` before every single run just to avoid confusion. +- `clean` gives a **true empty slate** and **bounds disk** — things filtering can't do. + +They cover different needs; neither subsumes the other. + +--- + +## 3. The `clean` verb + auto-clean + +Bounds the working DB and provides the "fresh slate for this repro" move. **Manual** and +**auto** are deliberately *different sweeps*: + +| | Trigger | Scope | Honors `autoCleanWindow`? | +|---|---|---|---| +| **Manual `clean`** | CLI, on demand | **full sweep** → fresh empty hot DB | **No** — you want it *all* gone (a slate) | +| **Auto-clean** | daemon timer, every `autoCleanInterval` | rows older than `autoCleanWindow` | **Yes** | + +Both honor `cleaningMode` and **both must `VACUUM`** afterward. + +### 3.1 Config (in logs-server config) +- `autoCleanInterval` — how often the daemon sweeps, e.g. `"10m"`. **Unset → auto-clean off.** +- `autoCleanWindow` — retention, e.g. `"24h"`. **Default `"24h"`** (deliberately *not* `1h`: + aggressive windows can delete logs mid-session if you step away; 24h is safe, and you use + manual `clean` when you actually want a slate). +- `cleaningMode` — `"archive" | "delete"`. + +Manual `clean` may take `--window` / `--mode` overrides, but defaults to full sweep + the +configured `cleaningMode`. + +### 3.2 The reclaim gotcha (don't skip) +SQLite `DELETE` **does not shrink the file** — 311 MB stays 311 MB until you `VACUUM`. So: + +- `delete` mode = `DELETE …` **+ `VACUUM`** (or run with `PRAGMA auto_vacuum=INCREMENTAL` set + at DB creation + `PRAGMA incremental_vacuum`). +- `archive` mode = write the archive file → `DELETE` the rows → **`VACUUM`**. + +`VACUUM INTO ''` conveniently produces a clean, defragmented copy in one step — useful for +the archive write (§4). VACUUM briefly takes a write lock; at dev volumes that's a non-issue, +but it's why the daemon (not an ad-hoc process) should own the timer. + +--- + +## 4. Archive format — lightest, never queried, no new dependency + +Assumption (stated by design): **archives are just-in-case cold storage; we will not query +them**, and they may get **big**. So optimize purely for **small + simple**, not for +queryability. + +- **Skip parquet.** Its only edge is columnar query via DuckDB/pandas — a toolchain we'd never + use here, bought at the cost of leaving the `sqlite3` world. +- The data is wildly repetitive (same `attrs` keys, `sentry.sdk.name`, `server.address` on every + row) → it compresses **10–20×**. **Compression is the whole game; the container barely + matters.** +- The sink is **Node**, whose stdlib `zlib` ships **Brotli** — excellent ratio, **zero extra + dependency**. + +**Chosen:** `sqlite3 .dump` (SQL text) → Brotli → `logs/archive/.sql.br`. Text dump +compresses best of all *and* carries no index bloat (indexes are just `CREATE` statements). +Restore if ever needed: `brotli -d < x.sql.br | sqlite3 restored.db`. + +**Noted alternative (slightly bigger, zero reopen-friction):** `VACUUM INTO .db` → Brotli → +`.db.br` — decompress and it's openable as a normal DB. Pick this only if "peek without +restoring" turns out to matter. + +--- + +## 5. The workflow this enables + +- **Routine:** let **auto-clean** (24h) handle accumulation. You generally don't think about it. +- **Look back / compare:** runs you care about carry a **`label`** → filter past runs out of the + retained 24h window without having cleaned between them. +- **Focused debug:** run **`clean`** to get a slate, then run the one repro, then read + everything (no label even needed at that point). Optionally also `LABEL=` it so it survives + in the archive/history meaningfully. + +**Cautions baked into the design:** +1. **`clean` is a *before-you-observe* action.** Do **not** put it at the *end* of an e2e + script — that deletes exactly the logs you'd debug a failure with. Put it at the **start** of + a focused run; rely on auto-clean for routine growth. +2. Skill guidance: when an agent is debugging and wants to disambiguate, it runs `clean` (or + filters by `label`) **first**, not after. + +--- + +## 6. Consumer surface — DEFERRED (future consideration) + +> **Status: not building this now.** No custom Sentry integration, no `davstackSinkDsn()` +> helper. Decision parked. This section records the research so picking it up later is cheap. +> For now, the small amount of consumer-side stamping `label` needs (§2.2) can be a few inline +> lines; we are explicitly *not* introducing a package abstraction yet. + +The eventual idea (future): package the per-service `beforeSend*` boilerplate as a +`davstackLogs({ project })` Sentry integration + a `davstackSinkDsn()` helper, so consumer setup +is ~one init line: + +```ts +// FUTURE — not implemented; illustration only +Sentry.init({ + dsn: davstackSinkDsn(), // dev → local sink, prod → real DSN + integrations: [davstackLogs({ project: "myapp-web" })], +}) +``` + +### What belongs where (research, for when we revisit) + +**Governing fact:** Sentry **log records and span data do NOT inherit `initialScope` tags** — +only *error events* do. So any custom attribute on logs/spans (`label`, `run_id`, `project`) +**must** be stamped in `beforeSendLog` / `beforeSendTransaction` (or an integration +event-processor); there is no scope shortcut. (This is *why* `beforeSend*` exists in titanium +and traffease today.) + +`beforeSend*` does three separable jobs — keep them distinct when deciding what to cut: + +1. **Correlation stamping** — `project` / `run_id` / `label` onto logs + spans. +2. **Routing** — the `davstack-logs.db` hint. **Demoted by this design.** +3. **Noise hygiene** — drop prod debug logs; **drop Sentry's own `"Sentry Logger ["` chatter** + (a real feedback-loop guard: `consoleLoggingIntegration` would otherwise re-ingest Sentry's + debug output); ignore internal-user error events. + +Where each piece lands under this architecture: + +| Piece | Verdict | Why | +|---|---|---| +| **Server-tier `beforeSend*` (correlation)** | **delete entirely** | server rows correlate by `trace_id` (auto); the server is downstream of "stamp once at the source" — it needs to stamp nothing | +| **`davstack-logs.db` hint** | **gone** | routing demoted to escape hatch | +| **`run_id`** | **drop** (or keep as free auto-grouping) | `label` (human) + `trace_id` (correlation) supersede it | +| **`project`** | **multi-service sinks only** | traffease's 4 tiers need it; for a single app `server.address` already separates client/server | +| **`label` (browser stamp)** | **the one must-keep** *if* we want look-back filtering | logs don't inherit scope, so it must be stamped on browser rows; ~3 lines or the integration's browser processor | +| **Noise guards** | **keep regardless** | not correlation; the chatter guard prevents a feedback loop | + +**Implication for the future integration:** its real remaining weight is **browser-side** (the +`label` stamp + noise guards + the DSN helper). **Server-side it is near-empty.** So when we +revisit, the open question is narrow: build the integration at all, or keep a tiny inline +browser `beforeSendLog`? Either way, *everything server-side comes out.* + +--- + +## 7. Empirical foundation (reused, not re-litigated) + +- Browser→server `trace_id` propagation is **confirmed** (02 §2 in traffease's node/py/react; + cross-stack re-validated on a Next.js app — **344 distinct trace_ids shared** between + confirmed-browser and server rows). This is the join key §2.3 stands on. Now used at **query + time**, which is strictly easier than the ingest-time use Approach C needed. +- Browser **spans** reach the sink **once `browserTracingIntegration` is enabled** on the client + (verified: `pageload`/`navigation` + child spans land). So the label feeder can be any browser + row (log or span). (Earlier "browser ships logs but not spans" was a *config gap*, not a + limitation.) + +--- + +## 8. Caveats & limits (honest) + +- **Trace grain is per-pageload/navigation.** Great for *per-run / per-session / per-db* labels; + **too coarse to key per-test** (one session-trace can span many tests; e.g. a single trace + observed spanning many Fast-Refresh cycles). Don't try to make `label` a per-test key — that's + the grain trap from 02. +- **`label` only resolves browser-rooted traces.** A purely server-originated trace (background + job, server→server call that starts its own trace, no browser ancestor) has no browser row + carrying the label → unresolvable by label. Small tail for browser-driven work; fall back to + time-slicing or `clean`-slate for those. +- **VACUUM** briefly write-locks; keep it on the daemon timer, fine at dev volumes. +- **Operational:** the daemon can wedge into "HTTP 200 but persists 0 rows" (stale DB handle). + Fix observed: `logs-server refresh --hard`. Worth a doctor check + maybe an auto-heal. + +--- + +## 9. What this supersedes / open questions + +- **Supersedes** 02's recommendation to build Approach C routing. Physical `db` routing (the + existing `db-route.ts` / `davstack-logs.db` stamp) is **demoted to an optional explicit escape + hatch** (`DB=` for the rare "I really want a separate file"), **not** the default and **not** + the thing we keep investing in. Candidate for eventual removal once `label` + `clean` prove + out — decide later; no need to rip it out now. +- **Build order:** (1) `clean` + auto-clean + VACUUM (highest leverage — bounds the DB, enables + the slate workflow, zero consumer change); (2) `label` end-to-end (a **tiny inline browser + `beforeSendLog`** stamp for now + `logs --label` query). **Deferred / future (see §6):** + packaging the stamp as a `davstackLogs()` integration and a `davstackSinkDsn()` helper — not + building now. +- **Open knobs:** exact CLI flags for `clean`; whether `label` should *also* be settable + server-side via a header for server-originated traces (only if the §8 tail bites); archive + retention/rotation in `logs/archive/` (cap count? age?); whether to expose a `labelled_logs` + VIEW vs a CLI flag only. +- **Skill integration:** wire "`clean` at the start of a repro / filter by `label`" into the + debugging skill's guidance (consumer-side, e.g. titanium), not the package. diff --git a/notes/open-agent-handoff-testing/.folder-meta.md b/notes/open-agent-handoff-testing/.folder-meta.md new file mode 100644 index 0000000..d8b5967 --- /dev/null +++ b/notes/open-agent-handoff-testing/.folder-meta.md @@ -0,0 +1,72 @@ +# Folder Meta: Open Agent Handoff Testing + +This folder tracks experiments for reducing the cost and friction of handing +work from a main agent to open-agents execution agents. + +## Files + +### `1-initial-comparison.md` + +Defines the first lean A/B/C comparison: + +- Main-agent written spec. +- No-spec handoff with title, recent history tail, and full-history pointer. +- Spec-writer middleman with title, recent history tail, and full-history + pointer. + +Use this as the current decision anchor. + +### `2-scenarios-and-isolation.md` + +Lists candidate test scenarios and isolation requirements. Focuses on realistic +`explore` and `fast-edit` usage: code reconnaissance, mechanical edits, +diagnosis instrumentation, and later fan-out composition. + +Use this when choosing fixtures and deciding cleanup/parallelism rules. + +### `3-promptfoo-integration-plan.md` + +Sketches how Promptfoo might wrap the eval workflow without owning open-agents +execution. The preferred shape is a thin local provider script that open-agents +can also run manually. + +Use this when turning the experiment into a repeatable matrix runner. + +### `4-candidate-backlog.md` + +Keeps the broader handoff variant list and longer-range hypotheses. This file is +not the active starting plan; it is a backlog for later expansion after the +three-way comparison produces signal. + +Use this when deciding what variants to test next. + +### `5-external-fixtures.md` + +Captures the fixture strategy for using pinned third-party repositories without +vendoring them into this repo. Covers manifests, isolated checkouts per run, +reset behavior, and the open questions around Promptfoo artifact layout. + +Use this when implementing fixture preparation and choosing the first CRUD app. + +### `6-fixture-repo-candidates.md` + +Records candidate GitHub repositories for the first external fixture, with a +short recommendation and tradeoffs. + +Use this when selecting the pinned repo/commit for the first prepare-script +spike. + +### `7-first-real-explore-eval.md` + +Plans the first non-smoke eval: a small read-only `explore` task against the +pinned Supabase starter fixture, including the three variants, candidate task, +measurements, and open questions. + +Use this before wiring the provider to launch real open-agents jobs. + +### `8-eval-artifact-convention.md` + +Defines where local eval outputs, copied open-agents results, job metadata, and +manual score files should live. + +Use this when making the Promptfoo provider persist inspectable run artifacts. diff --git a/notes/open-agent-handoff-testing/1-initial-comparison.md b/notes/open-agent-handoff-testing/1-initial-comparison.md new file mode 100644 index 0000000..d31a320 --- /dev/null +++ b/notes/open-agent-handoff-testing/1-initial-comparison.md @@ -0,0 +1,77 @@ +# Initial Handoff Comparison + +Related issues: + +- #28 Experiment with improved openagents spec writing flow +- #25 Add evals + +## Current Focus + +Start with three handoff strategies only. The wider candidate set belongs in +`4-candidate-backlog.md` until the small comparison proves what is worth +expanding. + +## Variants + +### A. Main-Agent Written Spec + +This is the baseline. The main agent writes the normal detailed open-agents spec +and passes that to `explore` or `fast-edit`. + +Hypothesis: + +- Best quality and scope control. +- Highest main-agent output cost. +- Slowest preparation path. + +### B. No Spec: Title Plus Recent Tail Plus Full-History Pointer + +The main agent writes only a concise task title or one-line intent, appends the +last X chars of the chat/history, and includes a path/link to the full history +file for deeper lookup. + +Hypothesis: + +- May be good enough for many realistic `explore` and `fast-edit` tasks. +- Avoids the spec-writer middleman entirely. +- Main risk is that the execution agent must synthesize and execute in one run. + +### C. Spec Writer: Title Plus Recent Tail Plus Full-History Pointer + +The main agent writes the concise title/intent. A cheap middleman agent receives +that plus recent history tail plus a full-history pointer, then writes a compact +execution spec. The actual execution agent receives only that generated spec. + +Hypothesis: + +- Better focus than variant B on harder tasks. +- Still much cheaper for the main agent than variant A. +- Only worth productizing if the quality gain beats the extra agent run. + +## First Decision Rule + +Compare B against C before assuming the spec-writer middleman is necessary. + +- If B is close to C, prefer no-spec history handoff for common tasks. +- If C is materially better, build spec writing as a first-class flow. +- If A is still clearly better, identify which task shapes need human/orchestrator + spec synthesis and avoid over-automating those. + +## Tail Sizes + +Initial tail-size sweep should stay small: + +- 30k chars. +- 50k chars. +- 100k chars only if 30k/50k are inconclusive. + +The full-history pointer should be present in B and C for every run so the tail +size measures "immediate context usefulness", not "does the agent have any +history at all?" + +## Documentation Style + +Prefer append-only experiment notes as runs happen. Edit existing sections only +when they are actively misleading or block comprehension. When the next decision +branch appears, start a new numbered file instead of repeatedly rewriting a long +plan. diff --git a/notes/open-agent-handoff-testing/2-scenarios-and-isolation.md b/notes/open-agent-handoff-testing/2-scenarios-and-isolation.md new file mode 100644 index 0000000..b0ebea7 --- /dev/null +++ b/notes/open-agent-handoff-testing/2-scenarios-and-isolation.md @@ -0,0 +1,150 @@ +# Scenarios And Isolation + +## What Existing Specs Suggest + +Local `.davstack` artifacts show the realistic open-agents work is mostly: + +- Read-only reconnaissance with explicit scope and path/line reporting. +- Mechanical edit specs with tight file lists and acceptance criteria. +- Diagnosis support, especially broad instrumentation or quote-only extraction. +- Fan-out work where one main question becomes several bounded sub-jobs. + +The initial eval should avoid abstract planning prompts. We should test the +things `explore` and `fast-edit` are already used for. + +## Scenario Requirements + +Each scenario should be: + +- Repeatable from a clean fixture/worktree. +- Independent, so variants can run in parallel. +- Cheap enough to run multiple times. +- Easy to grade with a mix of objective checks and quick human review. +- Representative of real `explore` or `fast-edit` usage. + +For edit scenarios, every variant needs either its own isolated copy/worktree or +a teardown step that restores exactly the touched files. Parallel runs should +not share a mutable checkout. + +## Scenario 1: Read-Only Code Recon + +Shape: + +- `explore` task. +- Ask where a concrete open-agents behavior lives and how it works. + +Example: + +> Find where open-agents builds provider command args and explain how ask vs +> force mode changes the Cursor, Gemini, and Agy adapters. Cite files and lines. + +Why this is useful: + +- Mirrors real `explore` specs. +- No cleanup required. +- Grading can check for specific source files and absence of invented behavior. + +Expected answer should mention: + +- `packages/open-agents/src/cli.ts` +- `packages/open-agents/src/adapters/cursor.ts` +- `packages/open-agents/src/adapters/gemini.ts` +- `packages/open-agents/src/adapters/agy.ts` +- Profile mode source in `packages/open-agents/src/profiles/*` + +## Scenario 2: Mechanical Fast-Edit + +Shape: + +- `fast-edit` task. +- Small, deterministic code change with clear acceptance criteria. + +Example: + +> Add a persisted `handoffVariant` metadata field to open-agents job records and +> cover the default/roundtrip behavior with unit tests. + +Isolation: + +- Run each variant in a separate temporary worktree or copied fixture repo. +- Teardown can remove the temp worktree/copy after metrics are collected. + +Objective checks: + +- Typecheck or targeted tests pass. +- Only expected open-agents files changed. +- Job record compatibility remains intact for old records. + +Risk: + +- This may be too product-shaped for the very first run. If so, replace it with + a smaller metadata-only edit in a toy package fixture. + +## Scenario 3: Diagnosis Support / Instrumentation Fan-Out + +Shape: + +- `fast-edit` task derived from diagnosis usage. +- Add broad, additive-only instrumentation across several named seams. + +Example: + +> Given hypotheses H1/H2/H3 and a path through three files, add additive debug +> logs at every seam with stable tags and enough fields to discriminate the +> hypotheses. Do not change behavior. + +Why this matters: + +- Existing diagnosis feedback says fast-edit specs underused fan-out and were + too verbatim. +- This tests whether history handoff preserves the doctrine: intent plus seam + list, not pasted code. + +Isolation: + +- Use a small fixture package or temporary worktree. +- Logs should be easy to grep by tag. +- Teardown deletes the fixture copy/worktree. + +Objective checks: + +- All expected tags exist. +- No non-instrumentation behavior changes. +- Typecheck passes. + +## Scenario 4: Multi-Explore Fan-Out Recon + +Shape: + +- Future scenario, not in the first run unless the first three are too easy. +- A middleman identifies that a broad question can split into several parallel + `explore` jobs. + +Why this is future-only: + +- It tests composition/fan-out, not just handoff shape. +- It likely needs a shared context artifact plus several generated task specs. + +This should become its own follow-up once the basic A/B/C handoff comparison is +working. + +## Cleanup And Parallelism Notes + +For read-only scenarios: + +- Variants can run in parallel in the same repo. +- Persist raw prompt, job id, result path, duration, and review score. + +For edit scenarios: + +- Prefer one temp worktree/copy per variant and per repeat. +- Record the exact base commit or fixture version. +- Collect diff stats before teardown. +- Keep failed worktrees until inspected, or copy their diffs into the result + artifact before cleanup. + +For diagnosis/instrumentation scenarios: + +- Fixture should include deterministic code paths and named seams. +- Expected instrumentation tags should be declared up front. +- Verification should be grep/typecheck, not a long runtime test. diff --git a/notes/open-agent-handoff-testing/3-promptfoo-integration-plan.md b/notes/open-agent-handoff-testing/3-promptfoo-integration-plan.md new file mode 100644 index 0000000..1e20896 --- /dev/null +++ b/notes/open-agent-handoff-testing/3-promptfoo-integration-plan.md @@ -0,0 +1,115 @@ +# Promptfoo Integration Plan + +## Why Consider Promptfoo Early + +Promptfoo may be worth using from the start if it gives us a consistent matrix +runner and report format without forcing open-agents to become an eval framework. + +The risk is over-integration: if promptfoo has to own process orchestration, +worktree setup, open-agents job polling, and human grading, the setup may become +heavier than the experiment. + +## Preferred Boundary + +Open-agents should remain responsible for: + +- Creating and running agent jobs. +- Persisting job specs/results/logs. +- Selecting adapter/profile/model. +- Reporting job status and result paths. + +Promptfoo should be responsible for: + +- Running the variant/scenario matrix. +- Passing variables into prompt templates. +- Capturing normalized JSON summaries from a local provider script. +- Rendering comparison reports. +- Hosting simple assertions where they are reliable. + +## Thin Local Provider Shape + +Promptfoo can call a local script instead of directly calling model APIs. + +Possible flow: + +1. Promptfoo selects `{scenario, variant, tailSize, provider, model}`. +2. Local provider script prepares an isolated run directory or worktree. +3. Script builds the handoff prompt for A, B, or C. +4. Script invokes open-agents. +5. Script records metrics and returns compact JSON to promptfoo. + +The local provider result should include: + +- `variant` +- `scenario` +- `promptChars` +- `tailChars` +- `generatedSpecChars` +- `durationMs` +- `exitCode` +- `resultPath` +- `filesChanged` +- `verificationCommand` +- `verificationExitCode` +- `summary` + +## Initial File Layout + +```text +packages/open-agents/evals/ + fixtures/ + scenarios.yaml + prompts/ + a-main-agent-spec.md + b-title-tail-history.md + c-spec-writer-title-tail-history.md + scripts/ + run-promptfoo-provider.ts + prepare-fixture.ts + score-result.ts + promptfooconfig.yaml +``` + +Keep this small at first. The script can shell out to the existing local source +entrypoint rather than requiring a published package. + +## Assertions + +Use objective assertions where they are cheap: + +- Did the run exit successfully? +- Did required files appear in the answer? +- Did required instrumentation tags appear? +- Did verification command pass? +- Did edit diff stay under expected paths? + +Use human review for the important qualitative parts: + +- Intent fidelity. +- Scope control. +- Whether the result would have saved main-agent time. +- Whether the handoff was pleasant enough to use again. + +## Open Questions + +- Does promptfoo handle long-running local providers comfortably enough for + agent jobs? +- Does it stream useful progress, or only final results? +- How awkward is one-worktree-per-variant setup inside promptfoo? +- Should the first implementation use promptfoo directly, or write the local + provider script first and wrap it with promptfoo once stable? +- Can promptfoo store enough metadata, or should run artifacts live entirely + under `.davstack/evals/` with promptfoo only linking to them? + +## Lean Recommendation + +Build the local provider script as if promptfoo will call it, then wire promptfoo +around it immediately if the integration feels simple. + +That gives us both options: + +- Run one scenario manually while debugging. +- Run the full A/B/C matrix through promptfoo once the provider is stable. + +This is different from delaying evals. The eval-shaped artifact comes first; the +promptfoo wrapper should be thin. diff --git a/notes/open-agent-handoff-testing/4-candidate-backlog.md b/notes/open-agent-handoff-testing/4-candidate-backlog.md new file mode 100644 index 0000000..545accb --- /dev/null +++ b/notes/open-agent-handoff-testing/4-candidate-backlog.md @@ -0,0 +1,399 @@ +# Candidate Backlog: Open Agents Spec Handoff Experiments + +Related issues: + +- #28 Experiment with improved openagents spec writing flow +- #25 Add evals + +## Problem + +Open-agents delegation is useful when a cheap/fast agent can absorb bounded +work, but the current handoff can be expensive in a different way: the main +agent often spends a lot of output tokens writing a detailed spec. That can make +delegation feel slower and more costly than just doing the work directly. + +The central question is not "should every task have a generated spec?" The +question is: + +> What is the cheapest handoff shape that still gives the execution agent enough +> context to succeed on the first try? + +## High-Level Hypotheses + +### H1: Main-agent specs are high quality but often over-priced + +The current pattern gives the execution agent a curated spec, but the main agent +pays the full cost of synthesis. This may be best for complex or risky edits, +but it is probably wasteful for straightforward explore and mechanical edit +tasks. + +### H2: Title plus history may be enough for many tasks + +A very short intent plus recent chat context and a pointer to full history may +let the execution agent infer the needed spec internally. If true, the separate +spec-writer agent is unnecessary for a large class of tasks. + +### H3: A spec-writer middleman helps when the execution agent needs focus + +Some tasks may fail when the execution agent has to both discover the task and +execute it. A cheap spec-writer agent may be valuable when it turns noisy chat +history into a compact, executable brief. + +### H4: Recent chat tail plus full-history pointer beats either alone + +The recent tail gives the agent immediately relevant context. The full-history +file gives it an escape hatch for older decisions without forcing all that text +into the prompt. + +### H5: The best strategy may depend on task shape + +Explore, fast-edit, bug diagnosis, and architectural planning may need different +handoff strategies. The eval should preserve task category instead of averaging +everything into one score. + +## Candidate Handoff Variants + +### A. Main Agent Written Spec + +Current baseline. + +Input to execution agent: + +- Detailed spec written by the orchestrating agent. +- Normal open-agents profile scaffold. + +Expected strengths: + +- Highest intent clarity. +- Best acceptance criteria. +- Lowest chance the execution agent chases stale context. + +Expected weaknesses: + +- Expensive output tokens from the main agent. +- Slow to prepare. +- May erase useful nuance from the original chat. + +### B. No Spec: Title Only + +Input to execution agent: + +- Task title or one-line intent only. +- Normal open-agents profile scaffold. + +Expected strengths: + +- Cheapest possible orchestration. +- Useful lower-bound baseline. + +Expected weaknesses: + +- Likely under-specified. +- May work only for tiny or obvious tasks. + +### C. No Spec: Title Plus Full-History Pointer + +Input to execution agent: + +- Task title or one-line intent. +- Path to full chat history file. +- Instruction to inspect only relevant portions. + +Expected strengths: + +- Very low main-agent output. +- Avoids pasting huge context into every run. +- Lets execution agent decide what history matters. + +Expected weaknesses: + +- Agent may not inspect the right parts. +- More tool use and latency. +- Harder to guarantee reproducibility if history file format changes. + +### D. No Spec: Title Plus Recent Tail Plus Full-History Pointer + +Input to execution agent: + +- Task title or one-line intent. +- Last X chars of chat history, probably 30k, 50k, and 100k buckets. +- Path to full chat history file for deeper lookup. + +Expected strengths: + +- Keeps the most relevant context in the prompt. +- Still avoids full-context prompt bloat. +- May remove the need for a spec-writer middleman. + +Expected weaknesses: + +- Still pays input-token cost for the tail. +- Recent chat can contain distractions. +- The execution agent still has to synthesize and execute in one pass. + +### E. Spec Writer: Title Plus Full-History Pointer + +Step 1 spec-writer agent input: + +- Task title or one-line intent. +- Path to full chat history file. +- Instruction to produce a compact execution spec. + +Step 2 execution agent input: + +- Generated spec. +- Normal open-agents profile scaffold. + +Expected strengths: + +- Main agent writes almost nothing. +- Execution agent gets a compact, focused spec. +- Full history is only read by the spec writer. + +Expected weaknesses: + +- Adds an extra agent run. +- Spec writer may miss context. +- More moving parts and more artifact management. + +### F. Spec Writer: Title Plus Recent Tail Plus Full-History Pointer + +Step 1 spec-writer agent input: + +- Task title or one-line intent. +- Recent chat tail. +- Path to full chat history file. +- Instruction to produce a compact execution spec. + +Step 2 execution agent input: + +- Generated spec. +- Normal open-agents profile scaffold. + +Expected strengths: + +- Likely strongest middleman variant. +- Spec writer sees recent nuance immediately. +- Execution agent gets a clean, bounded brief. + +Expected weaknesses: + +- Most expensive experimental variant besides baseline. +- May be overkill for simple tasks. +- Tail-size tuning matters. + +### G. Spec Writer Iteration + +Same as E or F, but the spec writer can run a self-check pass before handing off. + +Possible self-check questions: + +- Does the spec include the exact goal? +- Does it name scope and non-goals? +- Does it include acceptance criteria for edit tasks? +- Does it preserve user preferences from the chat? +- Does it avoid inventing requirements? + +Expected strengths: + +- Better generated specs for complex work. +- Useful for high-risk edits. + +Expected weaknesses: + +- More latency. +- More tokens. +- May collapse into the same cost problem we are trying to solve. + +## Task Fixtures + +Start with a small set of real-ish tasks instead of synthetic only. + +### Fixture 1: Hello World Mechanical Edit + +Purpose: + +- Prove the harness can run all variants. +- Check that metadata, artifacts, timings, and result paths are captured. + +Success criteria: + +- All variants produce comparable records. +- Execution result is easy to grade. +- No expensive manual analysis needed. + +### Fixture 2: Open-Agents Small Explore + +Example: + +- "Find where open-agents builds the Cursor command args and explain the mode + differences." + +Purpose: + +- Tests whether history helps a read-only task. +- Lower risk than editing. + +Success criteria: + +- Answer cites relevant files and lines. +- No invented behavior. + +### Fixture 3: Open-Agents Small Edit + +Example: + +- "Add a small metadata field to persisted job records and cover it with a unit + test." + +Purpose: + +- Tests whether generated/no-spec handoffs can produce acceptable edit specs. + +Success criteria: + +- Patch applies. +- Typecheck or targeted tests pass. +- Scope remains narrow. + +### Fixture 4: Ambiguous Planning Task + +Example: + +- "Improve open-agents spec handoff." + +Purpose: + +- Stress test whether the agent can recover intent from chat history. + +Success criteria: + +- Output identifies uncertainty. +- Does not over-commit to implementation without enough evidence. + +## Measurements + +Capture objective metrics for every run: + +- Variant id. +- Fixture id. +- Provider and model. +- Prompt character count. +- Recent-tail character count. +- Whether a full-history pointer was provided. +- Generated spec character count, if any. +- Wall-clock duration. +- Process exit code. +- Result path. +- Files changed. +- Test/typecheck command result, when applicable. + +Capture subjective review metrics: + +- Intent fidelity: 1-5. +- Scope control: 1-5. +- First-pass usefulness: 1-5. +- Would-use-again: yes/no. +- Notes on failure mode. + +For edit tasks, also capture: + +- Did it modify only expected files? +- Did it include or preserve acceptance criteria? +- Did it run the allowed verification command? +- Did the final diff require human cleanup? + +## Promptfoo Integration Shape + +Promptfoo is probably useful, but it should start as a thin runner/evaluator +around the same artifacts open-agents already writes. Avoid making promptfoo own +the whole execution pipeline too early. + +Proposed lightweight structure: + +```text +packages/open-agents/evals/ + fixtures/ + hello-world.yaml + small-explore.yaml + small-edit.yaml + prompts/ + main-agent-spec.md + no-spec-title.md + no-spec-title-history-pointer.md + no-spec-title-tail-history-pointer.md + spec-writer-history-pointer.md + spec-writer-tail-history-pointer.md + promptfooconfig.yaml + scripts/ + run-variant.ts + collect-results.ts +``` + +Promptfoo can compare prompt variants and call a local script provider that: + +1. Builds the variant prompt. +2. Runs open-agents with the selected adapter/profile. +3. Records job metadata and result artifacts. +4. Returns a compact JSON summary to promptfoo. + +This keeps open-agents as the source of truth for job execution while promptfoo +handles matrix runs, assertions, and reports. + +## Open Questions + +- Where does the full chat history file live in each host environment? +- Is the history file stable enough to pass directly, or do we need an export + command? +- Should recent-tail extraction happen in open-agents, a wrapper script, or the + main agent? +- Should generated specs be persisted as first-class job artifacts? +- Do we need a new `spec` profile, or is this just an `explore` profile with a + different output contract? +- Should spec-writer output be constrained to the existing ``, + ``, ``, `` shape? +- How much should the execution agent be told about the experiment variant? + +## Suggested Implementation Phases + +### Phase 1: Planning Artifacts + +- Write this experiment plan. +- Pick 3-4 fixtures. +- Define review rubric. +- Decide where chat history input comes from. + +### Phase 2: Minimal Manual Harness + +- Add scripts that run one fixture through each variant. +- Persist raw prompts, generated specs, job ids, results, and metrics. +- Avoid promptfoo until one or two variants have completed manually. + +### Phase 3: Promptfoo Wrapper + +- Add promptfoo config over the same fixtures and scripts. +- Use promptfoo for repeatable matrix runs and basic assertions. +- Keep human review fields in a simple JSON/Markdown report. + +### Phase 4: Productize Winning Flow + +- Add a user-facing command or profile only after the experiment shows a winner. +- Likely candidates: + - `open-agents spec` + - `open-agents submit --handoff title-tail-history` + - `open-agents submit --spec-writer` + +## Current Lean Recommendation + +Do not assume the middleman spec-writer is necessary. Test no-spec history +variants directly against spec-writer variants. + +The most promising early comparison is: + +1. Main-agent written spec. +2. No spec: title plus recent tail plus full-history pointer. +3. Spec writer: title plus recent tail plus full-history pointer. + +If variant 2 is close to variant 3, skip the middleman for common tasks and +reserve spec-writing for complex/risky work. If variant 3 is much better, build +the spec-writer path as a first-class open-agents flow. diff --git a/notes/open-agent-handoff-testing/5-external-fixtures.md b/notes/open-agent-handoff-testing/5-external-fixtures.md new file mode 100644 index 0000000..4e6978d --- /dev/null +++ b/notes/open-agent-handoff-testing/5-external-fixtures.md @@ -0,0 +1,132 @@ +# External Fixture Strategy + +## Direction We Agree On + +Use a pinned third-party mid-size repository as the first realistic eval target, +instead of testing only against `davstack` itself. + +Agreed principles: + +- Do not vendor a large third-party repo into normal git history. +- Keep only a small fixture manifest in this repo. +- Clone the target repo at a pinned commit during a prepare step. +- Put cloned/prepared repo copies in a gitignored eval workspace. +- Run variants in isolated checkouts so edits, generated files, and dependency + artifacts cannot contaminate other runs. +- Reset to the pinned base between runs. + +## Candidate Fixture Shape + +Preferred first fixture: + +- Next.js app. +- Supabase-backed or Supabase-like CRUD flow. +- Todo or simple CRUD domain. +- Medium sized: enough routing/components/server logic to make `explore` useful, + but not so large that setup and grading become the project. +- Has predictable local install/build/typecheck/test behavior. +- Licensed in a way that is comfortable for local eval use. + +The fixture does not need to be perfect. It needs to be stable, understandable, +and realistic enough to exercise the handoff strategies. + +## Manifest-First Model + +Store a small manifest in git, not the cloned repo. + +Example shape: + +```yaml +id: nextjs-supabase-todos +repo: https://github.com/example/nextjs-supabase-todos.git +commit: abc123 +sparse: + - app/** + - components/** + - lib/** + - supabase/** + - package.json + - pnpm-lock.yaml +setup: + - pnpm install --frozen-lockfile +verify: + - pnpm typecheck + - pnpm test +``` + +Sparse checkout is optional. It is useful only if the chosen repo has docs, +assets, generated files, or unrelated examples that would distract agents. + +## Isolation Model + +For edit-capable scenarios, each `{scenario, variant, repeat}` should get its +own checkout/worktree/copy. + +Why: + +- Variant A must not see files changed by variant B. +- Failed runs should be inspectable without blocking later runs. +- Parallel runs become much easier to reason about. + +For read-only scenarios, shared checkout is possible, but the first harness +should probably use the same isolation model for every scenario. Uniformity is +worth a little disk/time overhead while the eval is young. + +## Reset Behavior + +Every run starts from the same pinned commit plus the same setup result. + +Possible implementation: + +1. Maintain a local bare/mirror cache of the fixture repo. +2. Create a fresh worktree or clone for each run from the pinned commit. +3. Apply any scenario-specific seed patch or setup script. +4. Run the handoff variant. +5. Collect result, diff, verification output, and metadata. +6. Delete successful run dirs or keep only compressed artifacts. +7. Keep failed run dirs until inspected, or save their diff before cleanup. + +## Artifact Layout Is Still Provisional + +The earlier `.davstack/evals` sketch is a plausible starting point, not a +decision. Promptfoo may emit its own logs/results in a way that changes the best +layout. + +Open question: + +- Should Promptfoo own the run directory? +- Should open-agents own `.davstack/evals/runs/*` and Promptfoo only link to it? +- Should fixture clones live under `.davstack/evals`, `packages/open-agents/evals/.work`, + or a configurable temp path? + +Lean starting point: + +- Keep manifests and prompt templates in git. +- Keep cloned repos and run artifacts out of git. +- Let the first Promptfoo spike tell us whether `.davstack/evals` is the right + home or just a useful placeholder. + +## Macro Plan + +1. Pick one candidate CRUD fixture repo. +2. Record repo URL, commit SHA, install command, verify command, and any setup + quirks in a manifest. +3. Build a tiny prepare script that clones/resets one isolated checkout. +4. Run one read-only `explore` scenario manually against that checkout. +5. Run one edit scenario manually in three isolated checkouts for variants A/B/C. +6. Capture artifacts and review whether the layout is annoying. +7. Wrap the same provider/prepare flow with Promptfoo only after the manual + path is understandable. + +## Future Extension + +Once the basic three-way handoff comparison works, test the middleman as a +fan-out planner: + +- It reads the task and fixture context. +- It writes one shared context artifact. +- It emits several parallel `explore` or `fast-edit` specs. +- The harness runs them independently and collects/composes results. + +That is likely a high-leverage path, but it should not be mixed into the first +fixture-preparation spike. diff --git a/notes/open-agent-handoff-testing/6-fixture-repo-candidates.md b/notes/open-agent-handoff-testing/6-fixture-repo-candidates.md new file mode 100644 index 0000000..e798833 --- /dev/null +++ b/notes/open-agent-handoff-testing/6-fixture-repo-candidates.md @@ -0,0 +1,83 @@ +# Fixture Repo Candidates + +## Current Recommendation + +Start by trying `imbhargav5/nextbase-nextjs-supabase-starter` if the goal is a +realistic agent-eval fixture rather than the smallest possible todo app. + +Why: + +- It has real project structure: app code, database app, packages, docs, and + migrations. +- It advertises Supabase, CRUD/private items, typecheck, lint, tests, e2e tests, + and local Supabase lifecycle scripts. +- It is likely rich enough for both `explore` and `fast-edit` scenarios. + +Tradeoff: + +- It is more of a SaaS starter than a tiny todo app. +- It may be heavier to install and reset. +- It uses newer framework versions, so the commit and lockfile need to be pinned + tightly. + +Candidate: + +- + +## Other Candidates + +### Halo-Lab/next-supabase-todo + +- URL: +- Shape: small Next.js + Supabase todo app. +- Pros: focused todo domain, MIT license noted in README, likely easy for agents + to understand. +- Cons: very small; package scripts appear limited to dev/build/start; dependency + versions may be less deterministic if using `latest`. + +### Ali-Onar/nextjs-supabase-todo-app + +- URL: +- Shape: small-to-mid Next.js todo app with Supabase, Clerk, TypeScript, Material + UI, and drag/drop. +- Pros: more UI/application structure than the smallest examples. +- Cons: Clerk plus Supabase setup may add auth/environment friction; tests were + not obvious from a quick scan. + +### keiloktql/supabase-todo-app + +- URL: +- Shape: small Supabase todo app. +- Pros: MIT license visible, simple domain. +- Cons: likely too small for deeper eval tasks; setup details looked less + reliable from a quick scan. + +### clerk/clerk-supabase-nextjs + +- URL: +- Shape: official-ish Clerk/Supabase/Next.js integration example. +- Pros: auth/RLS integration may be realistic. +- Cons: not clearly todo/CRUD-focused; likely depends on dashboard/env setup; + less ideal for deterministic local evals. + +## Selection Criteria + +Before committing to a fixture, check: + +- Can it install from the pinned lockfile? +- Can it typecheck/build without hosted secrets? +- Does it have local tests, or can we define a cheap verification command? +- Does it have enough code structure for meaningful `explore` tasks? +- Does it have a safe edit surface for `fast-edit` tasks? +- Can Supabase setup be skipped, mocked, or run locally without long setup? + +## First Spike + +Try the recommended starter first, but treat that as a spike rather than a final +choice: + +1. Pin a commit. +2. Clone into a gitignored eval workspace. +3. Run install/typecheck/test commands once manually. +4. Record setup friction. +5. Decide whether to keep it or fall back to a smaller todo repo. diff --git a/notes/open-agent-handoff-testing/7-first-real-explore-eval.md b/notes/open-agent-handoff-testing/7-first-real-explore-eval.md new file mode 100644 index 0000000..fca9e0b --- /dev/null +++ b/notes/open-agent-handoff-testing/7-first-real-explore-eval.md @@ -0,0 +1,115 @@ +# First Real Explore Eval Plan + +## Goal + +Move from fixture-preparation smoke testing to one real `explore` task against +the pinned Supabase starter fixture. + +Keep the first real eval small. The purpose is to prove the A/B/C handoff shape, +not to solve all scoring and artifact problems at once. + +## Three Variants + +### A. Main-Agent Written Spec + +The prompt contains a normal curated `explore` spec: + +- clear goal; +- short context; +- concrete scope; +- expected citation style. + +This is the quality baseline and the current manual workflow. + +### B. No Spec: Title + Tail + History Pointer + +The prompt contains: + +- one-line task title; +- recent conversation/history tail; +- pointer to full history/context artifact; +- instruction to inspect only what is needed. + +For the first real eval, the "history" can be a small synthetic artifact instead +of the actual full chat export. We mainly need to test whether the execution +agent can turn lightweight context into a useful read-only answer. + +### C. Spec Writer Middleman + +Two-step flow: + +1. Spec-writer receives the same title + tail + history pointer as variant B. +2. It emits a compact `explore` spec. +3. Execution agent receives only that generated spec. + +For the first implementation, it is acceptable to simulate step 1 with a local +provider function or a separate open-agents run, as long as the generated spec is +persisted and measured. + +## Candidate Task + +Use a read-only architecture reconnaissance task: + +> In the pinned Supabase starter fixture, trace how the private-items CRUD flow +> is structured from route/page entry points through server/data access and +> Supabase calls. Cite relevant files and lines. Do not modify files. + +Why this task: + +- It matches real `explore` usage. +- It is concrete but not trivial. +- The answer can be graded by checking for route/page files, data-access files, + Supabase client usage, and path/line citations. +- It does not require installing dependencies or running the app. + +## Measurements + +Capture per variant: + +- prompt character count; +- tail character count; +- generated spec character count, if any; +- fixture checkout path; +- open-agents job id; +- result path; +- wall-clock duration; +- exit code; +- whether the run checkout was retained; +- answer character count. + +Review manually: + +- intent fidelity: 1-5; +- citation usefulness: 1-5; +- coverage of CRUD path: 1-5; +- hallucination / unsupported claims: yes/no; +- would use again: yes/no. + +Cheap automated assertions: + +- result includes at least one `path:line`-style citation; +- result mentions Supabase; +- result mentions private/items or the equivalent fixture domain term; +- result does not report edits/files changed. + +## Harness Steps + +1. Extend the provider so a scenario can run `explore` after preparing the + fixture checkout. +2. Persist each variant's final prompt/spec beside the Promptfoo result. +3. Keep successful run checkouts cleaned by default, but retain result artifacts. +4. Add `keepRun: true` for debugging failed runs. +5. Run one Promptfoo row with the three variants. +6. Manually inspect the three outputs and record the review scores. + +## Open Questions + +- Should the first real run use Cursor via open-agents immediately, or should we + first make the provider output the exact prompts/specs without launching + agents? +- Where should generated specs/results live if Promptfoo stores only table + output? +- Should the fixture checkout be retained for `explore` results so citations can + be clicked after cleanup, or is the mirror/pinned commit enough? +- How should we pass the synthetic history tail and full-history pointer into + Promptfoo vars without making the YAML noisy? diff --git a/notes/open-agent-handoff-testing/8-eval-artifact-convention.md b/notes/open-agent-handoff-testing/8-eval-artifact-convention.md new file mode 100644 index 0000000..8d594cf --- /dev/null +++ b/notes/open-agent-handoff-testing/8-eval-artifact-convention.md @@ -0,0 +1,161 @@ +# Eval Artifact Convention + +## Problem + +Promptfoo's table output is useful for a quick pass/fail view, but it is not +enough for inspecting agent-handoff evals later. We need durable local artifacts +per run: inputs, outputs, job metadata, scores, and notes. + +## Current Convention + +Ignored local eval artifacts live under one folder per run: + +```text +.davstack/evals/runs// +``` + +Each run folder should be self-contained enough to inspect later without knowing +which global history files, open-agents job files, or fixture cache were used. + +Preferred layout: + +```text +.davstack/evals/runs// + report.md + promptfoo-results.json + run.json + cases/ + a-main-agent-spec/ + history.jsonl + input.md + output.md + job.json + scores.json + manual-review.json + b-title-tail-history/ + history.jsonl + input.md + output.md + job.json + scores.json + manual-review.json +``` + +Promptfoo should own the primary machine-readable result file: + +```text +.davstack/evals/runs//promptfoo-results.json +``` + +Run evals with `--output` so Promptfoo writes that file directly instead of us +inventing a separate score format. + +For a manual comparison run, store: + +```text +promptfoo-results.json +report.md +run.json +cases/a-main-agent-spec/history.jsonl +cases/a-main-agent-spec/input.md +cases/a-main-agent-spec/output.md +cases/a-main-agent-spec/job.json +cases/a-main-agent-spec/scores.json +cases/a-main-agent-spec/manual-review.json +cases/b-title-tail-history/history.jsonl +cases/b-title-tail-history/input.md +cases/b-title-tail-history/output.md +cases/b-title-tail-history/job.json +cases/b-title-tail-history/scores.json +cases/b-title-tail-history/manual-review.json +``` + +The fixture checkout can live inside the run folder while the run is being +scored: + +```text +.davstack/evals/runs//-nextbase-supabase-starter/ +``` + +That checkout is noisy, but useful for clickable citation verification. Once a +run is scored, we can either keep the checkout temporarily or delete it and rely +on the pinned commit plus copied outputs. + +## History Snapshots + +If a variant uses history/context from outside the run folder, copy the exact +material into the run folder before launching the agent. + +Examples: + +- related synthetic Claude-history-shaped JSONL; +- redacted excerpts from `~/.claude/history.jsonl`; +- Cursor/agent chat exports, if used; +- a recent-tail text file; +- a `source-pointer.txt` file naming the original source path, timestamp, and + redaction/sampling rule. + +Do not make later review depend on mutable global files such as +`~/.claude/history.jsonl` or Cursor history databases. The run should contain +the exact history bytes the agent saw, or a clear statement that no history was +provided. + +## What Gets Committed + +Commit: + +- fixture manifests; +- Promptfoo configs; +- provider scripts; +- prompt templates; +- scoring conventions/docs. + +Do not commit: + +- cloned fixture repos; +- run checkouts; +- raw Promptfoo caches; +- bulky open-agents logs; +- manual score artifacts unless deliberately promoting a small golden fixture. + +## Next Automation Step + +The Promptfoo provider should write this artifact bundle automatically for each +case: + +- copy final prompt/spec for each variant; +- copy any history/context files used by that variant; +- copy open-agents result; +- copy job JSON; +- run citation-range validation; +- return objective metrics to Promptfoo so Promptfoo can score/assert them; +- write or update `manual-review.json` with blank human-review fields. + +Promptfoo should produce `promptfoo-results.json`. A tiny post-process script can +then merge in per-case `manual-review.json` files and render run-level +`report.md` for humans. + +## Promptfoo-Native Scoring + +Use Promptfoo assertions for objective checks: + +- result contains at least one `path:Lx-Ly` citation; +- citation-range validation returned zero invalid ranges; +- result mentions the target domain term, such as `private_items`; +- result has no `filesChanged`; +- provider exit status is success. + +Use Promptfoo metrics or named assertions for objective dimensions where +possible. Keep manual scores separate and additive: + +```text +cases//manual-review.json +``` + +Manual review should add only human judgment: + +- coverage; +- usefulness; +- hallucination risk; +- would-use-again; +- notes. diff --git a/packages/cli-utils/CHANGELOG.md b/packages/cli-utils/CHANGELOG.md index 0f31fce..c3545ab 100644 --- a/packages/cli-utils/CHANGELOG.md +++ b/packages/cli-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @davstack/cli-utils +## 1.3.2 + +### Patch Changes + +- Improve generated CLI help with examples, defaults, boolean negation forms, and string value hints. + ## 1.3.1 ### Patch Changes diff --git a/packages/cli-utils/__tests__/cli-help.test.ts b/packages/cli-utils/__tests__/cli-help.test.ts index 510d2c2..9897bfe 100644 --- a/packages/cli-utils/__tests__/cli-help.test.ts +++ b/packages/cli-utils/__tests__/cli-help.test.ts @@ -23,6 +23,26 @@ test('formatHelp renders name, description, flags, defaults', () => { expect(out).toContain('5180'); expect(out).toContain('Listen port'); expect(out).toContain('--verbose'); + expect(out).toContain('--no-verbose'); +}); + +test('formatHelp renders examples, defaults, and string value hints', () => { + const spec: CliSpec = { + name: 'demo', + description: 'A demo tool', + examples: ['demo . --fast'], + defaults: ['fast mode is on'], + flags: { + mode: { type: 'string', values: ['fast', 'slow'], description: 'Run mode' }, + }, + run: () => 0, + }; + const out = formatHelp([], spec); + expect(out).toContain('Examples:'); + expect(out).toContain('demo . --fast'); + expect(out).toContain('Defaults:'); + expect(out).toContain('fast mode is on'); + expect(out).toContain('--mode '); }); test('formatHelp shows env-var fallback when configured', () => { diff --git a/packages/cli-utils/package.json b/packages/cli-utils/package.json index 3787f53..debc937 100644 --- a/packages/cli-utils/package.json +++ b/packages/cli-utils/package.json @@ -1,6 +1,6 @@ { "name": "@davstack/cli-utils", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "type": "module", "description": "Tiny CLI helper shared by @davstack/logs-server and vitest-server.", diff --git a/packages/cli-utils/src/cli-help.ts b/packages/cli-utils/src/cli-help.ts index dca8e76..36cf1f2 100644 --- a/packages/cli-utils/src/cli-help.ts +++ b/packages/cli-utils/src/cli-help.ts @@ -27,6 +27,18 @@ export function formatHelp(commandPath: string[], spec: CliSpec): string { lines.push(` ${usageBits.join(' ')}`); lines.push(''); + if (node.examples && node.examples.length) { + lines.push('Examples:'); + for (const example of node.examples) lines.push(` ${example}`); + lines.push(''); + } + + if (node.defaults && node.defaults.length) { + lines.push('Defaults:'); + for (const value of node.defaults) lines.push(` ${value}`); + lines.push(''); + } + if (node.positionals && node.positionals.length) { lines.push('Positionals:'); for (const p of node.positionals) lines.push(formatPositional(p)); @@ -61,8 +73,13 @@ function formatPositional(p: Positional): string { function formatFlag(name: string, def: FlagSpec): string { const bits: string[] = []; - bits.push(`--${name}`); - bits.push(`<${def.type}>`); + if (def.type === 'boolean') { + bits.push(`--${name} / --no-${name}`); + } else { + const valueHint = def.values?.length ? def.values.join('|') : def.type; + bits.push(`--${name}`); + bits.push(`<${valueHint}>`); + } const meta: string[] = []; if (def.default !== undefined) meta.push(`default: ${JSON.stringify(def.default)}`); if (def.env) meta.push(`env: ${def.env}`); diff --git a/packages/cli-utils/src/cli.ts b/packages/cli-utils/src/cli.ts index 00251a0..f9bef00 100644 --- a/packages/cli-utils/src/cli.ts +++ b/packages/cli-utils/src/cli.ts @@ -19,6 +19,7 @@ export type FlagSpec = { required?: boolean; description?: string; env?: string; + values?: readonly string[]; }; export type Positional = { @@ -29,6 +30,8 @@ export type Positional = { export type CommandSpec = { description?: string; + examples?: string[]; + defaults?: string[]; positionals?: Positional[]; flags?: Record; commands?: Record; diff --git a/packages/context-compactor/CHANGELOG.md b/packages/context-compactor/CHANGELOG.md new file mode 100644 index 0000000..7bd5faf --- /dev/null +++ b/packages/context-compactor/CHANGELOG.md @@ -0,0 +1,7 @@ +# @davstack/context-compactor + +## 0.2.0 + +### Minor Changes + +- 81da911: Add `@davstack/context-compactor`: a TS-native, deterministic context-compaction package with structure-aware handlers (JSON, code, log, diff, text) and TOON output for arrays of uniform objects. open-agents now compacts spec-writer handoff history through it before generation, replacing the removed Headroom proxy integration. diff --git a/packages/context-compactor/__tests__/code.test.ts b/packages/context-compactor/__tests__/code.test.ts new file mode 100644 index 0000000..a8829d9 --- /dev/null +++ b/packages/context-compactor/__tests__/code.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest' +import { compact, compactCode } from '../src/index.js' + +const SAMPLE = `import { foo } from './foo' +export function doWork(input: string) { + const a = input.trim() + const b = a.toUpperCase() + const c = b.split('') + const d = c.reverse() + return d.join('') +} + +export class Widget { + render() { + const el = document.createElement('div') + el.className = 'widget' + el.textContent = 'hi' + document.body.appendChild(el) + return el + } +} +` + +describe('compactCode', () => { + test('keeps structural lines (imports, signatures, class)', () => { + const { text } = compactCode(SAMPLE, { signatureIndent: 4, minRun: 3 }) + expect(text).toContain("import { foo } from './foo'") + expect(text).toContain('export function doWork(input: string) {') + expect(text).toContain('export class Widget {') + expect(text).toContain('render() {') + }) + + test('collapses deep body runs into a marker + pointer', () => { + const { text, transforms, pointers } = compactCode(SAMPLE, { signatureIndent: 4, minRun: 3 }) + expect(transforms).toContain('code:body-elide') + expect(text).toMatch(/\/\/ … \d+ lines/) + const p = pointers.find((p) => p.kind === 'code-body') + expect(p).toBeDefined() + expect(p!.omitted).toBeGreaterThanOrEqual(3) + }) + + test('reduces line count', () => { + const { text } = compactCode(SAMPLE, { signatureIndent: 4, minRun: 3 }) + expect(text.split('\n').length).toBeLessThan(SAMPLE.split('\n').length) + }) + + test('preserves leading comments', () => { + const src = '// important note\nfunction f() {\n x\n y\n z\n w\n}' + const { text } = compactCode(src, { signatureIndent: 4, minRun: 3 }) + expect(text).toContain('// important note') + }) + + test('routes via compact() and leaves short input unchanged', () => { + const short = 'export const x = 1' + const result = compact(short, { contentType: 'code' }) + expect(result.transforms).toHaveLength(0) + expect(result.text).toBe(short) + }) + + test('compact() detects and compacts code', () => { + const result = compact(SAMPLE) + expect(result.contentType).toBe('code') + expect(result.transforms).toContain('code:body-elide') + expect(result.tokensSaved).toBeGreaterThan(0) + }) +}) diff --git a/packages/context-compactor/__tests__/detect.test.ts b/packages/context-compactor/__tests__/detect.test.ts new file mode 100644 index 0000000..34f972c --- /dev/null +++ b/packages/context-compactor/__tests__/detect.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest' +import { detectContentType } from '../src/detect.js' + +describe('detectContentType', () => { + test('valid json object', () => { + expect(detectContentType('{"a": 1, "b": [1, 2, 3]}')).toBe('json') + }) + + test('valid json array', () => { + expect(detectContentType('[{"id": 1}, {"id": 2}]')).toBe('json') + }) + + test('json-looking but invalid falls through to text', () => { + expect(detectContentType('{not json at all')).toBe('text') + }) + + test('unified diff', () => { + expect(detectContentType('diff --git a/x.ts b/x.ts\n@@ -1 +1 @@\n-old\n+new')).toBe('diff') + }) + + test('source code', () => { + expect(detectContentType('export function foo() {\n return 1\n}')).toBe('code') + }) + + test('log output', () => { + expect(detectContentType('2026-06-09 12:00:00 ERROR something failed')).toBe('log') + }) + + test('plain prose', () => { + expect(detectContentType('the quick brown fox jumps over the lazy dog')).toBe('text') + }) + + test('empty is unknown', () => { + expect(detectContentType(' ')).toBe('unknown') + }) +}) diff --git a/packages/context-compactor/__tests__/diff.test.ts b/packages/context-compactor/__tests__/diff.test.ts new file mode 100644 index 0000000..e6866ae --- /dev/null +++ b/packages/context-compactor/__tests__/diff.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'vitest' +import { compact, compactDiff } from '../src/index.js' + +const SAMPLE = `diff --git a/src/x.ts b/src/x.ts +index 1111111..2222222 100644 +--- a/src/x.ts ++++ b/src/x.ts +@@ -1,12 +1,12 @@ + context line 1 + context line 2 + context line 3 + context line 4 + context line 5 + context line 6 +-const old = 1 ++const next = 2 + context line 7 + context line 8 + context line 9 + context line 10 + context line 11 +` + +describe('compactDiff', () => { + test('keeps diff and hunk headers', () => { + const { text } = compactDiff(SAMPLE, { maxContext: 3 }) + expect(text).toContain('diff --git a/src/x.ts b/src/x.ts') + expect(text).toContain('--- a/src/x.ts') + expect(text).toContain('+++ b/src/x.ts') + expect(text).toContain('@@ -1,12 +1,12 @@') + }) + + test('keeps changed lines', () => { + const { text } = compactDiff(SAMPLE, { maxContext: 3 }) + expect(text).toContain('-const old = 1') + expect(text).toContain('+const next = 2') + }) + + test('collapses long context runs into a marker + pointer', () => { + const { text, transforms, pointers } = compactDiff(SAMPLE, { maxContext: 3 }) + expect(transforms).toContain('diff:context-collapse') + expect(text).toMatch(/… \d+ unchanged lines/) + const p = pointers.find((p) => p.kind === 'diff-context') + expect(p).toBeDefined() + expect(p!.omitted).toBeGreaterThan(3) + expect(text.split('\n').length).toBeLessThan(SAMPLE.split('\n').length) + }) + + test('does not collapse short context runs', () => { + const small = `@@ -1,2 +1,2 @@ + ctx a +-old ++new + ctx b` + const { text, transforms } = compactDiff(small, { maxContext: 3 }) + expect(transforms).toHaveLength(0) + expect(text).toContain(' ctx a') + expect(text).toContain(' ctx b') + }) + + test('compact() detects and compacts diffs', () => { + const result = compact(SAMPLE) + expect(result.contentType).toBe('diff') + expect(result.transforms).toContain('diff:context-collapse') + expect(result.tokensSaved).toBeGreaterThan(0) + }) +}) diff --git a/packages/context-compactor/__tests__/entropy.test.ts b/packages/context-compactor/__tests__/entropy.test.ts new file mode 100644 index 0000000..25bd1ea --- /dev/null +++ b/packages/context-compactor/__tests__/entropy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest' +import { isHighEntropy, normalizedEntropy } from '../src/entropy.js' + +describe('normalizedEntropy', () => { + test('uniform repetition has ~zero entropy', () => { + expect(normalizedEntropy('aaaaaaaa')).toBe(0) + }) + + test('empty string is zero', () => { + expect(normalizedEntropy('')).toBe(0) + }) + + test('a uuid scores higher than an english word', () => { + const uuid = '8f14e45f-ceea-4123-8f14-e45fceea4123' + expect(normalizedEntropy(uuid)).toBeGreaterThan(normalizedEntropy('success')) + }) +}) + +describe('isHighEntropy', () => { + test('preserves a uuid', () => { + expect(isHighEntropy('8f14e45f-ceea-4123-8f14-e45fceea4123')).toBe(true) + }) + + test('does not flag a short common word', () => { + expect(isHighEntropy('hello')).toBe(false) + }) + + test('does not flag a long string dominated by one character', () => { + // Normalized entropy measures alphabet-evenness, not word repetition: a + // skewed distribution (mostly 'a') scores low and is not preserved. + expect(isHighEntropy('aaaaaaaaaaaaaaaaaaaab')).toBe(false) + }) +}) diff --git a/packages/context-compactor/__tests__/json.test.ts b/packages/context-compactor/__tests__/json.test.ts new file mode 100644 index 0000000..8d605e6 --- /dev/null +++ b/packages/context-compactor/__tests__/json.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest' +import { compact } from '../src/index.js' + +/** Build the canonical "large tool-result array" shape (cf. logs-dump.md). */ +function bigArrayFixture(n = 1000): string { + const results = Array.from({ length: n }, (_, i) => ({ + id: i, + status: i === n - 2 ? 'error' : 'ok', + message: i === n - 2 ? 'Connection timeout' : 'Success', + uuid: '8f14e45f-ceea-4123-8f14-e45fceea4123', + timestamp: '2026-06-09T12:00:00Z', + })) + return JSON.stringify({ results }, null, 2) +} + +describe('compact() on JSON', () => { + test('caps a long array to head + sentinel + tail and stays valid JSON', () => { + const input = bigArrayFixture(1000) + const result = compact(input, { jsonFormat: 'json' }) + + expect(result.contentType).toBe('json') + expect(result.transforms).toContain('json:array-cap') + + const parsed = JSON.parse(result.text) as { results: unknown[] } + // 3 head + 1 sentinel + 1 tail + expect(parsed.results).toHaveLength(5) + expect((parsed.results[0] as { id: number }).id).toBe(0) + expect((parsed.results[4] as { id: number }).id).toBe(999) + expect(parsed.results[3]).toContain('omitted') + }) + + test('records a pointer with the omitted count', () => { + const result = compact(bigArrayFixture(1000)) + const arrayPointer = result.pointers.find((p) => p.kind === 'json-array-items') + expect(arrayPointer).toBeDefined() + expect(arrayPointer!.omitted).toBe(996) + }) + + test('preserves the full schema (every key) of kept items', () => { + const result = compact(bigArrayFixture(1000), { jsonFormat: 'json' }) + const parsed = JSON.parse(result.text) as { results: Array> } + expect(Object.keys(parsed.results[0]).sort()).toEqual([ + 'id', + 'message', + 'status', + 'timestamp', + 'uuid', + ]) + }) + + test('preserves high-entropy ids verbatim in kept items', () => { + const result = compact(bigArrayFixture(1000)) + expect(result.text).toContain('8f14e45f-ceea-4123-8f14-e45fceea4123') + }) + + test('achieves a large token reduction on the big array', () => { + const result = compact(bigArrayFixture(1000)) + expect(result.tokensSaved).toBeGreaterThan(0) + expect(result.ratio).toBeLessThan(0.2) + }) + + test('truncates long string values but keeps short ones', () => { + const input = JSON.stringify({ + short: 'keep me', + long: 'x'.repeat(1000), + }) + const result = compact(input, { jsonFormat: 'json' }) + expect(result.transforms).toContain('json:string-truncate') + const parsed = JSON.parse(result.text) as { short: string; long: string } + expect(parsed.short).toBe('keep me') + expect(parsed.long).toContain('chars]') + expect(parsed.long.length).toBeLessThan(200) + }) + + test('leaves small JSON untouched (below minLength)', () => { + const input = '{"a": 1, "b": 2}' + const result = compact(input) + expect(result.transforms).toHaveLength(0) + expect(result.text).toBe(input) + expect(result.tokensSaved).toBe(0) + }) + + test('does not cap arrays that are already short', () => { + const input = JSON.stringify({ items: [1, 2, 3] }) + ' '.repeat(300) + const result = compact(input) + expect(result.transforms).not.toContain('json:array-cap') + }) +}) diff --git a/packages/context-compactor/__tests__/log.test.ts b/packages/context-compactor/__tests__/log.test.ts new file mode 100644 index 0000000..1106040 --- /dev/null +++ b/packages/context-compactor/__tests__/log.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest' +import { compact, compactLog } from '../src/index.js' + +describe('compactLog', () => { + test('collapses runs of identical lines into a count marker', () => { + const input = Array.from({ length: 50 }, () => 'connection retry...').join('\n') + const { text, transforms, pointers } = compactLog(input, { ignoreTimestamps: true }) + expect(transforms).toContain('log:dedup') + expect(text).toContain('(×50)') + const p = pointers.find((p) => p.kind === 'log-dedup') + expect(p).toBeDefined() + expect(p!.omitted).toBe(49) + expect(text.split('\n').length).toBeLessThan(input.split('\n').length) + }) + + test('treats timestamped lines as near-identical', () => { + const input = [ + '2026-06-09T12:00:00Z heartbeat ok', + '2026-06-09T12:00:01Z heartbeat ok', + '2026-06-09T12:00:02Z heartbeat ok', + ].join('\n') + const { text } = compactLog(input, { ignoreTimestamps: true }) + expect(text).toContain('(×3)') + expect(text.split('\n')).toHaveLength(1) + }) + + test('ALWAYS keeps error / warn / stack-frame lines', () => { + const input = [ + 'info ok', + 'info ok', + 'ERROR boom failed', + 'ERROR boom failed', + 'Traceback (most recent call last):', + ' File "app.py", line 10, in ', + ' at Object. (/x.js:1:1)', + 'WARNING low disk', + ].join('\n') + const { text } = compactLog(input, { ignoreTimestamps: true }) + expect((text.match(/ERROR boom failed/g) ?? []).length).toBe(2) + expect(text).toContain('Traceback (most recent call last):') + expect(text).toContain('File "app.py"') + expect(text).toContain('at Object.') + expect(text).toContain('WARNING low disk') + }) + + test('leaves non-repeating lines unchanged', () => { + const input = 'line one\nline two\nline three' + const { text, transforms } = compactLog(input, { ignoreTimestamps: true }) + expect(transforms).toHaveLength(0) + expect(text).toBe(input) + }) + + test('compact() detects and compacts logs', () => { + const input = Array.from({ length: 30 }, () => 'DEBUG polling status').join('\n') + const result = compact(input) + expect(result.contentType).toBe('log') + expect(result.transforms).toContain('log:dedup') + expect(result.tokensSaved).toBeGreaterThan(0) + }) +}) diff --git a/packages/context-compactor/__tests__/text.test.ts b/packages/context-compactor/__tests__/text.test.ts new file mode 100644 index 0000000..dd0be10 --- /dev/null +++ b/packages/context-compactor/__tests__/text.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest' +import { compact, compactText } from '../src/index.js' + +function lines(n: number): string { + return Array.from({ length: n }, (_, i) => `line ${i}`).join('\n') +} + +describe('compactText', () => { + test('keeps head and tail, elides the middle with a marker + pointer', () => { + const input = lines(100) + const { text, transforms, pointers } = compactText(input, { + headLines: 5, + tailLines: 5, + dedup: false, + }) + expect(transforms).toContain('text:head-tail') + expect(text).toContain('line 0') + expect(text).toContain('line 4') + expect(text).toContain('line 99') + expect(text).toMatch(/…\[\+\d+ lines omitted\]/) + const p = pointers.find((p) => p.kind === 'text-middle') + expect(p).toBeDefined() + expect(p!.omitted).toBe(90) + expect(text.split('\n').length).toBeLessThan(input.split('\n').length) + }) + + test('leaves short input unchanged', () => { + const input = lines(4) + const { text, transforms } = compactText(input, { headLines: 5, tailLines: 5, dedup: false }) + expect(transforms).toHaveLength(0) + expect(text).toBe(input) + }) + + test('dedups exact-duplicate lines when enabled', () => { + const input = ['a', 'a', 'b', 'b', 'c'].join('\n') + const { text, transforms } = compactText(input, { headLines: 50, tailLines: 50, dedup: true }) + expect(transforms).toContain('text:dedup') + expect(text.split('\n')).toEqual(['a', 'b', 'c']) + }) + + test('compact() detects and compacts prose', () => { + const input = Array.from({ length: 80 }, (_, i) => `the quick brown fox number ${i}`).join('\n') + const result = compact(input) + expect(result.contentType).toBe('text') + expect(result.transforms).toContain('text:head-tail') + expect(result.tokensSaved).toBeGreaterThan(0) + }) +}) diff --git a/packages/context-compactor/__tests__/toon.test.ts b/packages/context-compactor/__tests__/toon.test.ts new file mode 100644 index 0000000..48d82db --- /dev/null +++ b/packages/context-compactor/__tests__/toon.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'vitest' +import { compact } from '../src/index.js' + +function bigArrayFixture(n = 1000): string { + const results = Array.from({ length: n }, (_, i) => ({ + id: i, + status: i === n - 2 ? 'error' : 'ok', + message: i === n - 2 ? 'Connection timeout' : 'Success', + timestamp: '2026-06-09T12:00:00Z', + })) + return JSON.stringify({ results }, null, 2) +} + +describe('compact() with TOON output', () => { + test('emits a tabular TOON header declaring fields once', () => { + const result = compact(bigArrayFixture(1000), { jsonFormat: 'toon' }) + expect(result.contentType).toBe('json') + expect(result.transforms).toContain('json:toon') + expect(result.transforms).toContain('json:array-cap') + // length + field list declared once: results[N]{id,status,message,timestamp}: + expect(result.text).toMatch(/results\[\d+\]\{id,status,message,timestamp\}:/) + }) + + test('keeps array rows uniform (no inline sentinel) and notes omissions in trailing comments', () => { + const result = compact(bigArrayFixture(1000), { jsonFormat: 'toon' }) + // The omission marker is a trailing `#` line, not an array element. + expect(result.text).toMatch(/#.*items omitted/) + const arrayPointer = result.pointers.find((p) => p.kind === 'json-array-items') + expect(arrayPointer?.omitted).toBe(996) + }) + + test('preserves field values verbatim in kept rows', () => { + const result = compact(bigArrayFixture(1000), { jsonFormat: 'toon' }) + // Timestamp contains ':' so TOON quotes it; the value is still present verbatim. + expect(result.text).toContain('2026-06-09T12:00:00Z') + expect(result.text).toContain('Success') + }) + + test('TOON beats JSON on a uniform-object array', () => { + const input = bigArrayFixture(1000) + const asJson = compact(input, { jsonFormat: 'json' }) + const asToon = compact(input, { jsonFormat: 'toon' }) + expect(asToon.tokensAfter).toBeLessThan(asJson.tokensAfter) + }) + + test("'auto' (the default) selects TOON for a large uniform array", () => { + const result = compact(bigArrayFixture(1000)) + expect(result.transforms).toContain('json:toon') + }) + + test("'auto' never produces more tokens than JSON", () => { + const input = bigArrayFixture(1000) + const auto = compact(input) + const asJson = compact(input, { jsonFormat: 'json' }) + expect(auto.tokensAfter).toBeLessThanOrEqual(asJson.tokensAfter) + }) +}) diff --git a/packages/context-compactor/package.json b/packages/context-compactor/package.json new file mode 100644 index 0000000..6f5d400 --- /dev/null +++ b/packages/context-compactor/package.json @@ -0,0 +1,37 @@ +{ + "name": "@davstack/context-compactor", + "version": "0.2.0", + "license": "MIT", + "type": "module", + "description": "Deterministic, TS-native context compaction for LLM agents: structure-aware reduction of tool outputs (JSON/code/logs) with retrieval pointers. No ML, no proxy.", + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "prepublishOnly": "tsup", + "test": "pnpm -w exec vitest run --project context-compactor" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist/**", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "dependencies": { + "@toon-format/toon": "^2.3.0" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/context-compactor/src/detect.ts b/packages/context-compactor/src/detect.ts new file mode 100644 index 0000000..3764baa --- /dev/null +++ b/packages/context-compactor/src/detect.ts @@ -0,0 +1,43 @@ +import type { ContentType } from './types.js' + +const CODE_KEYWORDS = [ + 'def ', + 'class ', + 'function ', + 'import ', + 'const ', + 'let ', + 'var ', + 'func ', + 'fn ', + 'pub ', + 'package ', + 'export ', +] + +const DIFF_PATTERN = /^(diff --git |@@ .* @@|\+\+\+ |--- )/m +const LOG_PATTERN = /\b(ERROR|WARN|WARNING|INFO|DEBUG|FATAL|TRACE)\b/ + +/** + * Heuristic content-type detection (no ML). Cheap and good enough because we + * usually compact known tool-output shapes. Order matters: diff before code + * (diffs contain code keywords); json is confirmed by an actual parse. + */ +export function detectContentType(content: string): ContentType { + const stripped = content.trim() + if (!stripped) return 'unknown' + + if (stripped.startsWith('{') || stripped.startsWith('[')) { + try { + JSON.parse(stripped) + return 'json' + } catch { + // not valid JSON; fall through + } + } + + if (DIFF_PATTERN.test(stripped)) return 'diff' + if (CODE_KEYWORDS.some((kw) => content.includes(kw))) return 'code' + if (LOG_PATTERN.test(content)) return 'log' + return 'text' +} diff --git a/packages/context-compactor/src/entropy.ts b/packages/context-compactor/src/entropy.ts new file mode 100644 index 0000000..88d92ac --- /dev/null +++ b/packages/context-compactor/src/entropy.ts @@ -0,0 +1,32 @@ +/** + * Shannon-entropy signal for preservation decisions. + * + * High-entropy tokens (UUIDs, hashes, random ids) are information-dense, often + * identifiers, and easily mangled by naive compression — so handlers preserve + * them. Ported from headroom's masks.EntropyScore. + */ + +/** Normalized character entropy in [0, 1] (Shannon entropy / log2(alphabet size)). */ +export function normalizedEntropy(text: string): number { + if (!text) return 0 + const counts = new Map() + for (const ch of text) counts.set(ch, (counts.get(ch) ?? 0) + 1) + const total = text.length + let entropy = 0 + for (const count of counts.values()) { + const p = count / total + entropy -= p * Math.log2(p) + } + const distinct = counts.size + const max = distinct > 1 ? Math.log2(distinct) : 1 + return max > 0 ? entropy / max : 0 +} + +/** + * True for tokens worth preserving verbatim: long enough to matter and above the + * entropy threshold. Short tokens rarely carry meaningful entropy. + */ +export function isHighEntropy(text: string, threshold = 0.85, minLength = 8): boolean { + if (text.length < minLength) return false + return normalizedEntropy(text) >= threshold +} diff --git a/packages/context-compactor/src/handlers/code.ts b/packages/context-compactor/src/handlers/code.ts new file mode 100644 index 0000000..7fad958 --- /dev/null +++ b/packages/context-compactor/src/handlers/code.ts @@ -0,0 +1,92 @@ +import type { Pointer } from '../types.js' + +export interface CodeCompactOptions { + /** Indent columns at/below which a *block-opening* line is kept as a signature. */ + signatureIndent: number + /** Minimum run of body lines before they are collapsed. */ + minRun: number +} + +export interface CodeCompactResult { + text: string + transforms: string[] + pointers: Pointer[] +} + +const STRUCTURAL = [ + 'import', + 'export', + 'from ', + 'def ', + 'class ', + 'function ', + 'func ', + 'fn ', + 'interface ', + 'type ', + 'struct ', + 'enum ', + 'pub ', + 'package ', +] + +function leadingIndent(line: string): number { + const m = line.match(/^[ \t]*/) + return m ? m[0].replace(/\t/g, ' ').length : 0 +} + +function isStructural(line: string, signatureIndent: number): boolean { + const trimmed = line.trim() + if (!trimmed) return false + if (trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('*')) return true + if (trimmed.startsWith('@')) return true // decorator + if (STRUCTURAL.some((kw) => trimmed.startsWith(kw))) return true + // Shallow block-opening lines (signatures, closing braces) are scaffolding. + if (leadingIndent(line) <= signatureIndent) { + if (/[{([:]\s*$/.test(trimmed) || /=>\s*\{?\s*$/.test(trimmed)) return true + if (/^[)\]}]/.test(trimmed)) return true + } + return false +} + +/** + * Line-based code compaction. Keeps structural lines (imports/exports, defs, + * classes, signatures, decorators, comments and low-indent scaffolding) and + * collapses consecutive deeply-indented body lines into a `// … N lines` marker. + * Heuristic only: it does not parse the language, so unusual formatting (no + * indentation, minified code) will simply collapse less. + */ +export function compactCode(content: string, opts: CodeCompactOptions): CodeCompactResult { + const lines = content.split('\n') + const transforms = new Set() + const pointers: Pointer[] = [] + const out: string[] = [] + + let run: string[] = [] + + const flush = () => { + if (run.length === 0) return + if (run.length >= opts.minRun) { + const indent = run[0].match(/^[ \t]*/)?.[0] ?? '' + const note = `${indent}// … ${run.length} lines` + transforms.add('code:body-elide') + pointers.push({ kind: 'code-body', omitted: run.length, note }) + out.push(note) + } else { + out.push(...run) + } + run = [] + } + + for (const line of lines) { + if (isStructural(line, opts.signatureIndent)) { + flush() + out.push(line) + } else { + run.push(line) + } + } + flush() + + return { text: out.join('\n'), transforms: [...transforms], pointers } +} diff --git a/packages/context-compactor/src/handlers/diff.ts b/packages/context-compactor/src/handlers/diff.ts new file mode 100644 index 0000000..89137d6 --- /dev/null +++ b/packages/context-compactor/src/handlers/diff.ts @@ -0,0 +1,64 @@ +import type { Pointer } from '../types.js' + +export interface DiffCompactOptions { + /** Runs of unchanged context longer than this are collapsed. */ + maxContext: number +} + +export interface DiffCompactResult { + text: string + transforms: string[] + pointers: Pointer[] +} + +const HEADER = /^(diff --git |index |--- |\+\+\+ |@@ )/ + +function isHeader(line: string): boolean { + return HEADER.test(line) +} + +function isChange(line: string): boolean { + // '+'/'-' but not the '+++'/'---' file headers (caught by isHeader first). + return (line.startsWith('+') || line.startsWith('-')) && !isHeader(line) +} + +/** + * Line-based unified-diff compaction. Keeps every diff/hunk header and every + * changed line (`+`/`-`), and collapses runs of unchanged context (leading + * space, or blank) longer than `maxContext` into a `… N unchanged lines` marker. + * Heuristic only: it assumes standard unified-diff line prefixes. + */ +export function compactDiff(content: string, opts: DiffCompactOptions): DiffCompactResult { + const lines = content.split('\n') + const transforms = new Set() + const pointers: Pointer[] = [] + const out: string[] = [] + + let run: string[] = [] + + const flush = () => { + if (run.length === 0) return + if (run.length > opts.maxContext) { + const note = `… ${run.length} unchanged lines` + transforms.add('diff:context-collapse') + pointers.push({ kind: 'diff-context', omitted: run.length, note }) + out.push(note) + } else { + out.push(...run) + } + run = [] + } + + for (const line of lines) { + if (isHeader(line) || isChange(line)) { + flush() + out.push(line) + } else { + // context (leading space) or blank line + run.push(line) + } + } + flush() + + return { text: out.join('\n'), transforms: [...transforms], pointers } +} diff --git a/packages/context-compactor/src/handlers/json.ts b/packages/context-compactor/src/handlers/json.ts new file mode 100644 index 0000000..c9041b4 --- /dev/null +++ b/packages/context-compactor/src/handlers/json.ts @@ -0,0 +1,83 @@ +import type { Pointer } from '../types.js' + +export interface JsonCompactOptions { + /** Leading array items kept verbatim. */ + headItems: number + /** Trailing array items kept verbatim. */ + tailItems: number + /** String values longer than this are truncated. */ + maxStringLength: number + /** Chars retained from the head of a truncated string. */ + stringHead: number +} + +export interface JsonCompactResult { + text: string + transforms: string[] + pointers: Pointer[] +} + +/** + * Structure-aware JSON compaction. Unlike a blind truncation it preserves the + * full schema (every key, all booleans/nulls/numbers, short values) so the LLM + * still sees the shape, while collapsing the two things that dominate token + * count in real tool output: + * + * - long arrays -> keep first N + last M items, drop the middle + * - long strings -> keep a head, drop the tail + * + * Every elision leaves an inline marker and a Pointer for retrieval. Re-emitting + * with compact JSON.stringify also drops all pretty-print whitespace for free. + * + * Adapted from headroom's json_handler + array-cap heuristic, but operating on + * the parsed structure (cleaner and exact) rather than a character mask. + */ +export function compactJson(content: string, opts: JsonCompactOptions): JsonCompactResult { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + return { text: content, transforms: [], pointers: [] } + } + + const transforms = new Set() + const pointers: Pointer[] = [] + const keep = opts.headItems + opts.tailItems + + const walk = (node: unknown): unknown => { + if (Array.isArray(node)) { + // Only cap when it actually saves more than one element. + if (node.length > keep + 1) { + const omitted = node.length - keep + const note = `…[+${omitted} of ${node.length} items omitted]` + transforms.add('json:array-cap') + pointers.push({ kind: 'json-array-items', omitted, note }) + return [ + ...node.slice(0, opts.headItems).map(walk), + note, + ...node.slice(node.length - opts.tailItems).map(walk), + ] + } + return node.map(walk) + } + + if (node && typeof node === 'object') { + const out: Record = {} + for (const [key, value] of Object.entries(node)) out[key] = walk(value) + return out + } + + if (typeof node === 'string' && node.length > opts.maxStringLength) { + const omitted = node.length - opts.stringHead + const note = `…[+${omitted} chars]` + transforms.add('json:string-truncate') + pointers.push({ kind: 'json-string', omitted, note }) + return node.slice(0, opts.stringHead) + note + } + + return node + } + + const reduced = walk(parsed) + return { text: JSON.stringify(reduced), transforms: [...transforms], pointers } +} diff --git a/packages/context-compactor/src/handlers/log.ts b/packages/context-compactor/src/handlers/log.ts new file mode 100644 index 0000000..56ef287 --- /dev/null +++ b/packages/context-compactor/src/handlers/log.ts @@ -0,0 +1,69 @@ +import type { Pointer } from '../types.js' + +export interface LogCompactOptions { + /** Strip leading timestamps before comparing lines for near-identity. */ + ignoreTimestamps: boolean +} + +export interface LogCompactResult { + text: string + transforms: string[] + pointers: Pointer[] +} + +const SEVERITY = /\b(ERROR|WARN|WARNING|FATAL|Exception|Traceback)\b/ +const STACK_FRAME = /(^\s*at\s)|(\bFile ")/ +const TIMESTAMP = + /^\s*(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?Z?|\[\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\]|\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)\s*/ + +function alwaysKeep(line: string): boolean { + return SEVERITY.test(line) || STACK_FRAME.test(line) +} + +function normalize(line: string, ignoreTimestamps: boolean): string { + return ignoreTimestamps ? line.replace(TIMESTAMP, '') : line +} + +/** + * Line-based log compaction. Collapses runs of identical (or, with timestamps + * stripped, near-identical) lines into ` (×N)`, but ALWAYS keeps lines + * carrying a severity keyword or a stack frame. Heuristic only: "near-identical" + * means equal after an optional leading-timestamp strip, not fuzzy matching. + */ +export function compactLog(content: string, opts: LogCompactOptions): LogCompactResult { + const lines = content.split('\n') + const transforms = new Set() + const pointers: Pointer[] = [] + const out: string[] = [] + + let i = 0 + while (i < lines.length) { + const line = lines[i] + if (alwaysKeep(line)) { + out.push(line) + i++ + continue + } + + const key = normalize(line, opts.ignoreTimestamps) + let count = 1 + while ( + i + count < lines.length && + !alwaysKeep(lines[i + count]) && + normalize(lines[i + count], opts.ignoreTimestamps) === key + ) { + count++ + } + + if (count > 1) { + transforms.add('log:dedup') + pointers.push({ kind: 'log-dedup', omitted: count - 1, note: `(×${count})` }) + out.push(`${line} (×${count})`) + } else { + out.push(line) + } + i += count + } + + return { text: out.join('\n'), transforms: [...transforms], pointers } +} diff --git a/packages/context-compactor/src/handlers/text.ts b/packages/context-compactor/src/handlers/text.ts new file mode 100644 index 0000000..efea3ef --- /dev/null +++ b/packages/context-compactor/src/handlers/text.ts @@ -0,0 +1,59 @@ +import type { Pointer } from '../types.js' + +export interface TextCompactOptions { + /** Lines kept from the head. */ + headLines: number + /** Lines kept from the tail. */ + tailLines: number + /** Drop exact-duplicate lines before head/tail trimming. */ + dedup: boolean +} + +export interface TextCompactResult { + text: string + transforms: string[] + pointers: Pointer[] +} + +/** + * Line-based prose compaction. Optionally drops exact-duplicate lines, then + * keeps the first K and last K lines, replacing the middle with an + * `…[+N lines omitted]` marker. Heuristic only: it has no sentence awareness, + * so it can cut mid-paragraph — fine for boilerplate-heavy tool output. + */ +export function compactText(content: string, opts: TextCompactOptions): TextCompactResult { + const transforms = new Set() + const pointers: Pointer[] = [] + let lines = content.split('\n') + + if (opts.dedup) { + const seen = new Set() + const deduped: string[] = [] + for (const line of lines) { + const trimmed = line.trim() + if (trimmed && seen.has(line)) { + transforms.add('text:dedup') + continue + } + if (trimmed) seen.add(line) + deduped.push(line) + } + lines = deduped + } + + const keep = opts.headLines + opts.tailLines + if (lines.length > keep + 1) { + const omitted = lines.length - keep + const note = `…[+${omitted} lines omitted]` + transforms.add('text:head-tail') + pointers.push({ kind: 'text-middle', omitted, note }) + const out = [ + ...lines.slice(0, opts.headLines), + note, + ...lines.slice(lines.length - opts.tailLines), + ] + return { text: out.join('\n'), transforms: [...transforms], pointers } + } + + return { text: lines.join('\n'), transforms: [...transforms], pointers } +} diff --git a/packages/context-compactor/src/handlers/toon.ts b/packages/context-compactor/src/handlers/toon.ts new file mode 100644 index 0000000..1dbcced --- /dev/null +++ b/packages/context-compactor/src/handlers/toon.ts @@ -0,0 +1,93 @@ +import { encode } from '@toon-format/toon' +import type { Pointer } from '../types.js' + +export interface ToonCompactOptions { + /** Leading array items kept verbatim. */ + headItems: number + /** Trailing array items kept verbatim. */ + tailItems: number + /** String values longer than this are truncated. */ + maxStringLength: number + /** Chars retained from the head of a truncated string. */ + stringHead: number +} + +export interface ToonCompactResult { + text: string + transforms: string[] + pointers: Pointer[] +} + +/** + * JSON compaction that serializes to TOON (Token-Oriented Object Notation) + * instead of JSON. TOON declares an array's length + field names once and + * streams comma-separated rows, so arrays of uniform objects cost far fewer + * tokens than JSON (which repeats every key per element). + * + * Unlike the JSON handler, the array cap keeps the kept items *uniform* (no + * inline sentinel) so the TOON table stays clean; omission/truncation notes are + * appended as trailing `#` comment lines (read by the LLM, ignored as data). + */ +export function compactJsonToon(content: string, opts: ToonCompactOptions): ToonCompactResult { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + return { text: content, transforms: [], pointers: [] } + } + + const transforms = new Set(['json:toon']) + const pointers: Pointer[] = [] + const keep = opts.headItems + opts.tailItems + + const walk = (node: unknown): unknown => { + if (Array.isArray(node)) { + if (node.length > keep + 1) { + const omitted = node.length - keep + transforms.add('json:array-cap') + pointers.push({ + kind: 'json-array-items', + omitted, + note: `+${omitted} of ${node.length} array items omitted`, + }) + return [ + ...node.slice(0, opts.headItems).map(walk), + ...node.slice(node.length - opts.tailItems).map(walk), + ] + } + return node.map(walk) + } + + if (node && typeof node === 'object') { + const out: Record = {} + for (const [key, value] of Object.entries(node)) out[key] = walk(value) + return out + } + + if (typeof node === 'string' && node.length > opts.maxStringLength) { + const omitted = node.length - opts.stringHead + transforms.add('json:string-truncate') + pointers.push({ kind: 'json-string', omitted, note: `string truncated (+${omitted} chars)` }) + return node.slice(0, opts.stringHead) + `…[+${omitted} chars]` + } + + return node + } + + const reduced = walk(parsed) + let text: string + try { + text = encode(reduced) + } catch { + // encode is total over the JSON data model, but stay defensive: fall back + // to compact JSON if it ever throws. + transforms.delete('json:toon') + return { text: JSON.stringify(reduced), transforms: [...transforms], pointers } + } + + if (pointers.length) { + text += '\n' + pointers.map((pointer) => `# ${pointer.note}`).join('\n') + } + + return { text, transforms: [...transforms], pointers } +} diff --git a/packages/context-compactor/src/index.ts b/packages/context-compactor/src/index.ts new file mode 100644 index 0000000..5e97004 --- /dev/null +++ b/packages/context-compactor/src/index.ts @@ -0,0 +1,129 @@ +import { detectContentType } from './detect.js' +import { compactCode } from './handlers/code.js' +import { compactDiff } from './handlers/diff.js' +import { compactJson } from './handlers/json.js' +import { compactLog } from './handlers/log.js' +import { compactText } from './handlers/text.js' +import { compactJsonToon } from './handlers/toon.js' +import type { CompactOptions, CompactResult, ContentType, Pointer } from './types.js' + +const DEFAULTS = { + minLength: 200, + jsonHeadItems: 3, + jsonTailItems: 1, + jsonMaxStringLength: 200, + jsonStringHead: 120, + jsonFormat: 'auto' as const, + codeSignatureIndent: 4, + codeMinRun: 3, + logIgnoreTimestamps: true, + diffMaxContext: 3, + textHeadLines: 5, + textTailLines: 5, + textDedup: false, +} + +interface HandlerResult { + text: string + transforms: string[] + pointers: Pointer[] +} + +/** Cheap chars/4 token estimate (same heuristic headroom uses). */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4) +} + +/** Per-type reducers. Each mirrors `compactJson`'s `(content, opts) => result`. */ +const HANDLERS: Partial HandlerResult>> = + { + json: (content, o) => { + const jsonOpts = { + headItems: o.jsonHeadItems ?? DEFAULTS.jsonHeadItems, + tailItems: o.jsonTailItems ?? DEFAULTS.jsonTailItems, + maxStringLength: o.jsonMaxStringLength ?? DEFAULTS.jsonMaxStringLength, + stringHead: DEFAULTS.jsonStringHead, + } + const format = o.jsonFormat ?? DEFAULTS.jsonFormat + if (format === 'json') return compactJson(content, jsonOpts) + if (format === 'toon') return compactJsonToon(content, jsonOpts) + // 'auto': emit whichever serialization is smaller (never worse than JSON). + const asJson = compactJson(content, jsonOpts) + const asToon = compactJsonToon(content, jsonOpts) + const count = o.countTokens ?? estimateTokens + return count(asToon.text) <= count(asJson.text) ? asToon : asJson + }, + code: (content, o) => + compactCode(content, { + signatureIndent: o.codeSignatureIndent ?? DEFAULTS.codeSignatureIndent, + minRun: o.codeMinRun ?? DEFAULTS.codeMinRun, + }), + log: (content, o) => + compactLog(content, { + ignoreTimestamps: o.logIgnoreTimestamps ?? DEFAULTS.logIgnoreTimestamps, + }), + diff: (content, o) => + compactDiff(content, { + maxContext: o.diffMaxContext ?? DEFAULTS.diffMaxContext, + }), + text: (content, o) => + compactText(content, { + headLines: o.textHeadLines ?? DEFAULTS.textHeadLines, + tailLines: o.textTailLines ?? DEFAULTS.textTailLines, + dedup: o.textDedup ?? DEFAULTS.textDedup, + }), + } + +/** + * Compact a single blob of content. Detects the content type and routes to a + * structure-aware handler. Deterministic, synchronous, no ML, no network. + * + * Each content type (json/code/log/diff/text) has its own handler; 'unknown' + * and anything below `minLength` pass through unchanged. + */ +export function compact(content: string, options: CompactOptions = {}): CompactResult { + const count = options.countTokens ?? estimateTokens + const contentType: ContentType = options.contentType ?? detectContentType(content) + const tokensBefore = count(content) + const minLength = options.minLength ?? DEFAULTS.minLength + + let text = content + let transforms: string[] = [] + let pointers: Pointer[] = [] + + const handler = HANDLERS[contentType] + if (content.length >= minLength && handler) { + const result = handler(content, options) + text = result.text + transforms = result.transforms + pointers = result.pointers + } + + const tokensAfter = count(text) + return { + text, + contentType, + tokensBefore, + tokensAfter, + tokensSaved: Math.max(0, tokensBefore - tokensAfter), + ratio: tokensBefore > 0 ? tokensAfter / tokensBefore : 1, + transforms, + pointers, + } +} + +export { detectContentType } from './detect.js' +export { normalizedEntropy, isHighEntropy } from './entropy.js' +export { compactJson } from './handlers/json.js' +export { compactCode } from './handlers/code.js' +export { compactLog } from './handlers/log.js' +export { compactDiff } from './handlers/diff.js' +export { compactText } from './handlers/text.js' +export { compactJsonToon } from './handlers/toon.js' +export type { JsonCompactOptions, JsonCompactResult } from './handlers/json.js' +export type { ToonCompactOptions, ToonCompactResult } from './handlers/toon.js' +export type { CodeCompactOptions, CodeCompactResult } from './handlers/code.js' +export type { LogCompactOptions, LogCompactResult } from './handlers/log.js' +export type { DiffCompactOptions, DiffCompactResult } from './handlers/diff.js' +export type { TextCompactOptions, TextCompactResult } from './handlers/text.js' +export type { CompactOptions, CompactResult, ContentType, Pointer } from './types.js' diff --git a/packages/context-compactor/src/types.ts b/packages/context-compactor/src/types.ts new file mode 100644 index 0000000..d04168a --- /dev/null +++ b/packages/context-compactor/src/types.ts @@ -0,0 +1,63 @@ +/** High-level content categories used to route to a compaction handler. */ +export type ContentType = 'json' | 'code' | 'log' | 'diff' | 'text' | 'unknown' + +/** A breadcrumb left where content was elided, so a caller can retrieve the original. */ +export interface Pointer { + /** What kind of elision this marks (e.g. 'json-array-items', 'json-string'). */ + kind: string + /** How much was dropped (items / lines / chars, depending on kind). */ + omitted: number + /** Inline marker text that was inserted into the output. */ + note: string +} + +export interface CompactOptions { + /** Force a content type, skipping detection. */ + contentType?: ContentType + /** Token counter. Defaults to a chars/4 estimator. */ + countTokens?: (s: string) => number + /** Skip compaction below this length (chars). Default 200. */ + minLength?: number + /** JSON: number of leading array items to keep verbatim. Default 3. */ + jsonHeadItems?: number + /** JSON: number of trailing array items to keep verbatim. Default 1. */ + jsonTailItems?: number + /** JSON: string values longer than this are truncated. Default 200. */ + jsonMaxStringLength?: number + /** + * JSON output serialization. 'json' = compact JSON; 'toon' = Token-Oriented + * Object Notation (keys declared once per array, comma rows — far cheaper for + * arrays of uniform objects); 'auto' = emit whichever is smaller. Default 'auto'. + */ + jsonFormat?: 'json' | 'toon' | 'auto' + /** code: indent columns at/below which a line is structural. Default 4. */ + codeSignatureIndent?: number + /** code: minimum run of body lines before collapsing. Default 3. */ + codeMinRun?: number + /** log: strip leading timestamps before comparing lines. Default true. */ + logIgnoreTimestamps?: boolean + /** diff: collapse runs of unchanged context longer than this. Default 3. */ + diffMaxContext?: number + /** text: lines kept from the head. Default 5. */ + textHeadLines?: number + /** text: lines kept from the tail. Default 5. */ + textTailLines?: number + /** text: drop exact-duplicate lines. Default false. */ + textDedup?: boolean +} + +export interface CompactResult { + /** The compacted content. */ + text: string + /** Detected (or forced) content type. */ + contentType: ContentType + tokensBefore: number + tokensAfter: number + tokensSaved: number + /** tokensAfter / tokensBefore (1 = no change). */ + ratio: number + /** Names of transforms that fired, e.g. ['json:array-cap']. */ + transforms: string[] + /** Breadcrumbs for elided content. */ + pointers: Pointer[] +} diff --git a/packages/context-compactor/tsconfig.json b/packages/context-compactor/tsconfig.json new file mode 100644 index 0000000..9865451 --- /dev/null +++ b/packages/context-compactor/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "resolveJsonModule": true + }, + "include": ["src/**/*", "__tests__/**/*"] +} diff --git a/packages/context-compactor/tsup.config.ts b/packages/context-compactor/tsup.config.ts new file mode 100644 index 0000000..8d00a38 --- /dev/null +++ b/packages/context-compactor/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: ["esm"], + target: "node20", + outDir: "dist", + dts: true, + clean: true, + splitting: false, + sourcemap: true, +}) diff --git a/packages/fn-orpc/README.md b/packages/fn-orpc/README.md new file mode 100644 index 0000000..1e9349b --- /dev/null +++ b/packages/fn-orpc/README.md @@ -0,0 +1,55 @@ +# @davstack/fn-orpc + +oRPC adapter for [`@davstack/fn`](https://www.npmjs.com/package/@davstack/fn). Turn a directly-callable `fn` into an [oRPC](https://orpc.unnoq.com/) procedure. + +`@davstack/fn` is a transport-agnostic server-function convention. This package provides `initProcedureFactory`, which bridges an `fn` into an oRPC procedure while preserving the input/output types. + +## Install + +```bash +npm install @davstack/fn-orpc @davstack/fn @orpc/server zod +``` + +## Usage + +```ts +import { os } from '@orpc/server'; +import { createFn } from '@davstack/fn'; +import { initProcedureFactory } from '@davstack/fn-orpc'; +import { z } from 'zod'; + +// Build a factory bound to your oRPC builder. +// Use `os.$context()` if your fns rely on a typed context. +const fnProcedure = initProcedureFactory(os); + +// Define a transport-agnostic fn +const createChat = createFn({ + name: 'createChat', + inputSchema: z.object({ title: z.string() }), + handler: async ({ input }) => { + return { id: 'chat_123', title: input.title }; + }, +}); + +// Turn it into an oRPC procedure +const createChatProcedure = fnProcedure(createChat); + +// A router is just a plain object of procedures +const router = { + createChat: createChatProcedure, +}; +``` + +The procedure's input and output types are preserved from the `fn`. oRPC has no +query/mutation distinction at the builder level, so the factory takes only the +`fn`. If you need OpenAPI metadata you can chain `.route(...)` on the result: + +```ts +const createChatProcedure = fnProcedure(createChat).route({ + method: 'POST', + path: '/chats', +}); +``` + +When the `fn` declares an `outputSchema`, it is forwarded to oRPC's `.output(...)` +so responses are validated as well. diff --git a/packages/fn-orpc/__tests__/fn-orpc.test.ts b/packages/fn-orpc/__tests__/fn-orpc.test.ts new file mode 100644 index 0000000..fb8481a --- /dev/null +++ b/packages/fn-orpc/__tests__/fn-orpc.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, expectTypeOf } from 'vitest'; +import { os, createRouterClient } from '@orpc/server'; +import { createFn, FnError } from '@davstack/fn'; +import { z } from 'zod'; +import { initProcedureFactory } from '../src'; + +describe('initProcedureFactory', () => { + it('returns a function', () => { + const fnProcedure = initProcedureFactory(os); + expect(typeof fnProcedure).toBe('function'); + }); + + it('builds a procedure from a fn with inputSchema + handler and calls it via a router client', async () => { + const fnProcedure = initProcedureFactory(os); + + const createChat = createFn({ + name: 'createChat', + description: 'Create a chat', + inputSchema: z.object({ title: z.string() }), + handler: async ({ input }) => { + return { id: 'chat_123', title: input.title }; + }, + }); + + const createChatProcedure = fnProcedure(createChat); + expect(createChatProcedure).toBeDefined(); + + const router = { createChat: createChatProcedure }; + const client = createRouterClient(router, { context: {} }); + + const result = await client.createChat({ title: 'hello' }); + expect(result).toEqual({ id: 'chat_123', title: 'hello' }); + }); + + it('works for a fn with no inputSchema (callable with no args)', async () => { + const fnProcedure = initProcedureFactory(os); + + const ping = createFn({ + name: 'ping', + handler: async () => { + return { ok: true }; + }, + }); + + const pingProcedure = fnProcedure(ping); + const router = { ping: pingProcedure }; + const client = createRouterClient(router, { context: {} }); + + const result = await client.ping(); + expect(result).toEqual({ ok: true }); + }); + + it('maps oRPC context to fn ctx', async () => { + const fnProcedure = initProcedureFactory(os.$context<{ userId: string }>()); + + const whoami = createFn({ + name: 'whoami', + handler: async ({ ctx }: { ctx: { userId: string } }) => { + return { userId: ctx.userId }; + }, + }); + + const whoamiProcedure = fnProcedure(whoami); + const router = { whoami: whoamiProcedure }; + const client = createRouterClient(router, { + context: { userId: 'u_1' }, + }); + + const result = await client.whoami(); + expect(result).toEqual({ userId: 'u_1' }); + }); + + it('propagates a FnError thrown by the handler', async () => { + const fnProcedure = initProcedureFactory(os); + + const findChat = createFn({ + name: 'findChat', + inputSchema: z.object({ id: z.string() }), + handler: async () => { + throw new FnError({ code: 'NOT_FOUND' }); + }, + }); + + const findChatProcedure = fnProcedure(findChat); + const router = { findChat: findChatProcedure }; + const client = createRouterClient(router, { context: {} }); + + await expect(client.findChat({ id: 'missing' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('rejects when called with invalid input', async () => { + const fnProcedure = initProcedureFactory(os); + + const createChat = createFn({ + name: 'createChat', + inputSchema: z.object({ title: z.string() }), + handler: async ({ input }) => ({ id: '1', title: input.title }), + }); + + const router = { createChat: fnProcedure(createChat) }; + const client = createRouterClient(router, { context: {} }); + + // title should be a string; passing a number must reject validation. + await expect( + // @ts-expect-error - invalid input on purpose + client.createChat({ title: 123 }), + ).rejects.toThrow(); + }); + + it('validates output against the fn outputSchema when present', async () => { + const fnProcedure = initProcedureFactory(os); + + const getCount = createFn({ + name: 'getCount', + outputSchema: z.object({ count: z.number() }), + handler: async () => { + return { count: 7 }; + }, + }); + + const router = { getCount: fnProcedure(getCount) }; + const client = createRouterClient(router, { context: {} }); + + const result = await client.getCount(); + expect(result).toEqual({ count: 7 }); + }); + + it('preserves input/output types through the router client (type-level)', async () => { + const fnProcedure = initProcedureFactory(os); + + const createChat = createFn({ + name: 'createChat', + inputSchema: z.object({ title: z.string() }), + handler: async ({ input }) => { + return { id: 'chat_123', title: input.title }; + }, + }); + + const router = { createChat: fnProcedure(createChat) }; + const client = createRouterClient(router, { context: {} }); + + const result = await client.createChat({ title: 'x' }); + + expectTypeOf(result).toEqualTypeOf<{ id: string; title: string }>(); + expectTypeOf(client.createChat) + .parameter(0) + .toEqualTypeOf<{ title: string }>(); + }); +}); diff --git a/packages/fn-orpc/package.json b/packages/fn-orpc/package.json new file mode 100644 index 0000000..97822e1 --- /dev/null +++ b/packages/fn-orpc/package.json @@ -0,0 +1,43 @@ +{ + "name": "@davstack/fn-orpc", + "version": "0.1.0", + "license": "MIT", + "type": "module", + "description": "oRPC adapter for @davstack/fn — turn directly-callable fns into oRPC procedures.", + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "prepublishOnly": "tsup", + "test": "pnpm -w exec vitest run --project fn-orpc" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist/**", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "dependencies": { + "@orpc/server": "^1.14.6" + }, + "peerDependencies": { + "@davstack/fn": ">=2.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "devDependencies": { + "@davstack/fn": "workspace:*", + "@types/node": "^25.9.1", + "tsup": "^8.0.0", + "typescript": "^5.0.0", + "zod": ">=3.25.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/fn-orpc/src/index.ts b/packages/fn-orpc/src/index.ts new file mode 100644 index 0000000..e668f78 --- /dev/null +++ b/packages/fn-orpc/src/index.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import type { + Builder, + ProcedureBuilder, + DecoratedProcedure, + Schema, +} from '@orpc/server'; +import { zInferInput, zInfer, ZodTypeAny, Simplify } from '@davstack/fn'; + +/** + * Creates a factory function for oRPC procedures from Fn definitions. + * + * @remarks + * This function deliberately uses a more flexible type definition than the full + * Fn type to avoid complex type compatibility issues between context types and + * middleware, mirroring the approach used by `@davstack/fn-trpc`. + * + * We extract only the essential properties needed for procedure creation + * (`inputSchema`, `outputSchema`, `handler`) rather than using the full Fn type + * to prevent TypeScript errors with context type constraints when used with + * specific service contexts. + * + * Unlike the tRPC adapter, oRPC has no query/mutation distinction at the builder + * level (that is a `.route({ method })` concern), so this factory takes only the + * fn — call `.route(...)` on the returned procedure if you need OpenAPI metadata. + * + * The type safety for the actual function implementations is handled elsewhere in + * the system, so this pragmatic approach allows us to create procedures without + * excessive type gymnastics. + * + * @param procedureBuilder - An oRPC `Builder` or `ProcedureBuilder`, e.g. `os` + * or `os.$context()`. + */ +export function initProcedureFactory< + TProcedureBuilder extends + | Builder + | ProcedureBuilder, +>(procedureBuilder: TProcedureBuilder) { + return function createOrpcProcedureFromFn< + TFn extends { + inputSchema?: ZodTypeAny; + outputSchema?: ZodTypeAny; + handler: (...args: any[]) => any; + } & ((...args: any[]) => Promise), + >(fn: TFn) { + if (!fn.handler) { + throw new Error('Handler not defined'); + } + + type InputType = TFn['inputSchema'] extends ZodTypeAny + ? Simplify> + : void; + + // Prefer the declared output schema for the output type when present, + // otherwise fall back to the fn's resolved return type. We read this from + // the fn's own call signature (`Awaited>`) rather than + // `TFn['handler']`, because the handler property on a createFn result is + // typed generically (FnHandler<...>) and does not carry the concrete + // return type, whereas the callable signature does. + type OutputType = TFn['outputSchema'] extends ZodTypeAny + ? zInfer + : Awaited>; + + // A clean oRPC procedure result type with input/output inference wired up. + // oRPC infers client input/output from the schema generics, where a + // schema is a StandardSchemaV1, so we surface the fn's + // resolved input/output types through those slots. + type ProcedureResult = DecoratedProcedure< + Record, + Record, + Schema, + Schema, + Record, + Record + >; + + const inputSchema = (fn.inputSchema ?? z.void()) as ZodTypeAny; + + // Map oRPC's `context` to fn's `ctx`. Call the fn directly so the + // original FnError (and its stack) propagate unchanged. + const handler = async ({ + input, + context, + }: { + input: any; + context: any; + }) => { + return fn({ input, ctx: context }); + }; + + const withInput = (procedureBuilder as any).input(inputSchema); + + const built = fn.outputSchema + ? withInput.output(fn.outputSchema).handler(handler) + : withInput.handler(handler); + + return built as unknown as ProcedureResult; + }; +} diff --git a/packages/fn-orpc/tsconfig.json b/packages/fn-orpc/tsconfig.json new file mode 100644 index 0000000..83851f6 --- /dev/null +++ b/packages/fn-orpc/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@davstack/fn": ["../fn/src/index.ts"] + } + }, + "include": ["src/**/*", "__tests__/**/*"] +} diff --git a/packages/fn-orpc/tsup.config.ts b/packages/fn-orpc/tsup.config.ts new file mode 100644 index 0000000..b90b0aa --- /dev/null +++ b/packages/fn-orpc/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: ["esm"], + target: "node18", + outDir: "dist", + dts: true, + clean: true, + splitting: false, + sourcemap: true, + external: ["@davstack/fn"], +}) diff --git a/packages/fn-trpc/README.md b/packages/fn-trpc/README.md new file mode 100644 index 0000000..d6946aa --- /dev/null +++ b/packages/fn-trpc/README.md @@ -0,0 +1,44 @@ +# @davstack/fn-trpc + +tRPC adapter for [`@davstack/fn`](https://www.npmjs.com/package/@davstack/fn). Turn a directly-callable `fn` into a tRPC procedure. + +`@davstack/fn` is a transport-agnostic server-function convention. This package provides `initProcedureFactory`, which bridges an `fn` into a tRPC `query` or `mutation` procedure while preserving the input/output types. + +## Install + +```bash +npm install @davstack/fn-trpc @davstack/fn @trpc/server zod +``` + +## Usage + +```ts +import { initTRPC } from '@trpc/server'; +import { createFn } from '@davstack/fn'; +import { initProcedureFactory } from '@davstack/fn-trpc'; +import { z } from 'zod'; + +const t = initTRPC.create(); + +// Build a factory bound to your base procedure +const fnProcedure = initProcedureFactory(t.procedure); + +// Define a transport-agnostic fn +const createChat = createFn({ + name: 'createChat', + inputSchema: z.object({ title: z.string() }), + handler: async ({ input }) => { + return { id: 'chat_123', title: input.title }; + }, +}); + +// Turn it into a tRPC procedure +const createChatProcedure = fnProcedure(createChat, 'mutation'); + +// Use it in a router +const appRouter = t.router({ + createChat: createChatProcedure, +}); +``` + +Pass `'query'` instead of `'mutation'` to produce a query procedure. diff --git a/packages/fn-trpc/__tests__/init-procedure-factory.test.ts b/packages/fn-trpc/__tests__/init-procedure-factory.test.ts new file mode 100644 index 0000000..09d02b5 --- /dev/null +++ b/packages/fn-trpc/__tests__/init-procedure-factory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'vitest'; +import { initProcedureFactory } from '../src'; + +import { initTRPC } from '@trpc/server'; +import { MutationProcedure } from '@trpc/server/unstable-core-do-not-import'; +import { expectTypeOf } from 'vitest'; +import { z } from 'zod'; +import { createFn } from '@davstack/fn'; + +describe('initProcedureFactory', () => { + test('should create a hello world test', () => { + // Simple hello world test to verify the test setup works + const message = 'Hello World'; + expect(message).toBe('Hello World'); + }); + + test('should initialize a procedure factory', () => { + // Mock procedure builder + + const t = initTRPC.create(); + const baseProcedure = t.procedure; + + // Create factory + const createFnProcedure = initProcedureFactory(baseProcedure); + + // Verify factory is a function + expect(typeof createFnProcedure).toBe('function'); + + const createChat = createFn({ + name: 'createChat', + description: 'Create a chat', + tags: ['chat'], + inputSchema: z.object({ title: z.string() }), + handler: async ({ input }) => { + return { id: 'chat_123', title: input.title }; + }, + }); + + const createChatProcedure = createFnProcedure(createChat, 'mutation'); + + expect(createChatProcedure).toBeDefined(); + expectTypeOf().not.toBeUndefined(); + + // console.log('createChatProcedure', createChatProcedure); + // console.log('createChatProcedure.type:', typeof createChatProcedure); + expectTypeOf({} as any).toEqualTypeOf< + MutationProcedure<{ + input: { title: string }; + output: { id: string; title: string }; + meta: Record; + }> + >({} as any); + }); +}); diff --git a/packages/fn-trpc/package.json b/packages/fn-trpc/package.json new file mode 100644 index 0000000..acb0b47 --- /dev/null +++ b/packages/fn-trpc/package.json @@ -0,0 +1,43 @@ +{ + "name": "@davstack/fn-trpc", + "version": "0.1.0", + "license": "MIT", + "type": "module", + "description": "tRPC adapter for @davstack/fn — turn directly-callable fns into tRPC procedures.", + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "prepublishOnly": "tsup", + "test": "pnpm -w exec vitest run --project fn-trpc" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist/**", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "dependencies": { + "@trpc/server": "^11.4.3" + }, + "peerDependencies": { + "@davstack/fn": ">=2.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "devDependencies": { + "@davstack/fn": "workspace:*", + "@types/node": "^25.9.1", + "tsup": "^8.0.0", + "typescript": "^5.0.0", + "zod": ">=3.25.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/fn-trpc/src/index.ts b/packages/fn-trpc/src/index.ts new file mode 100644 index 0000000..fd3561c --- /dev/null +++ b/packages/fn-trpc/src/index.ts @@ -0,0 +1,96 @@ +import { + AnyProcedureBuilder, + MutationProcedure, + QueryProcedure, +} from '@trpc/server/unstable-core-do-not-import'; +import { z } from 'zod'; +import { zInferInput, ZodTypeAny, Simplify } from '@davstack/fn'; + +/** + * Creates a factory function for tRPC procedures from Fn definitions + * + * @remarks + * This function deliberately uses a more flexible type definition than the full Fn type + * to avoid complex type compatibility issues between context types and middleware. + * + * Prior to introducing OptionallyRequiredField, using Fn worked fine, + * but after adding that type helper, we encountered type compatibility issues with our more + * structured argument types. + * + * We're extracting only the essential properties needed for procedure creation rather + * than using the full Fn type to prevent TypeScript errors with OptionallyRequiredField + * and context type constraints when used with specific service contexts. + * + * In the future, we could consider using FnDef instead of Fn as the constraint, as it + * contains the definition properties without the call signatures that include the + * OptionallyRequiredField complexity. + * + * The type safety for the actual function implementations is handled elsewhere in the + * system, so this pragmatic approach allows us to create procedures without excessive + * type gymnastics. + */ +export function initProcedureFactory< + TProcedureBuilder extends AnyProcedureBuilder, +>(procedureBuilder: TProcedureBuilder) { + return function createTrpcProcedureFromFn< + TFn extends { + inputSchema?: ZodTypeAny; + handler: (...args: any[]) => any; + }, + TType extends 'mutation' | 'query', + >(fn: TFn & { (...args: any[]): Promise }, type: TType) { + if (!fn.handler) { + throw new Error('Handler not defined'); + } + + type InputType = TFn['inputSchema'] extends ZodTypeAny + ? zInferInput + : void; + + type OutputType = Awaited>; + // type OutputType = + // ReturnType extends Promise + // ? TOutput + // : never; + + type InputOutput = Simplify<{ + input: InputType; + output: OutputType; + meta: Record; + }>; + + type ProcedureResult = TType extends 'mutation' + ? MutationProcedure + : QueryProcedure; + + const inputSchema = fn.inputSchema ?? z.void(); + // TODO: Potentially integrate outputSchema validation in the future + + // Define the handler function that will be used for both query and mutation + // This avoids duplicating the implementation and creating different stack frames + const handler = async ({ ctx, input }: { ctx: any; input: any }) => { + try { + // Directly call the function to avoid introducing unnecessary stack frames + + return await fn({ input, ctx }); + } catch (error) { + // Don't wrap the error here - let it propagate with its original stack + throw error; + } + }; + + if (type === 'mutation') { + return procedureBuilder + .input(inputSchema) + .mutation(handler) as unknown as ProcedureResult; + } + + if (type === 'query') { + return procedureBuilder + .input(inputSchema) + .query(handler) as unknown as ProcedureResult; + } + + throw new Error('Type not defined'); + }; +} diff --git a/packages/fn-trpc/tsconfig.json b/packages/fn-trpc/tsconfig.json new file mode 100644 index 0000000..83851f6 --- /dev/null +++ b/packages/fn-trpc/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@davstack/fn": ["../fn/src/index.ts"] + } + }, + "include": ["src/**/*", "__tests__/**/*"] +} diff --git a/packages/fn-trpc/tsup.config.ts b/packages/fn-trpc/tsup.config.ts new file mode 100644 index 0000000..b90b0aa --- /dev/null +++ b/packages/fn-trpc/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: ["esm"], + target: "node18", + outDir: "dist", + dts: true, + clean: true, + splitting: false, + sourcemap: true, + external: ["@davstack/fn"], +}) diff --git a/packages/fn/.eslintrc.js b/packages/fn/.eslintrc.js new file mode 100644 index 0000000..5333046 --- /dev/null +++ b/packages/fn/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: ['@davstack/eslint-config/library.js'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + }, +}; diff --git a/packages/fn/CHANGELOG.md b/packages/fn/CHANGELOG.md new file mode 100644 index 0000000..6d74c8d --- /dev/null +++ b/packages/fn/CHANGELOG.md @@ -0,0 +1,19 @@ +# @davstack/fn + +## 0.1.6 + +### Patch Changes + +- change internals for trpc integration to try fix build type issues + +## 0.1.5 + +### Patch Changes + +- fix tsup building broken types bug (zInfer name conflict) + +## 0.1.0 + +### Minor Changes + +- bfde332: publish initial service version diff --git a/packages/fn/FUTURE-add-retries.spec.md b/packages/fn/FUTURE-add-retries.spec.md new file mode 100644 index 0000000..04445fb --- /dev/null +++ b/packages/fn/FUTURE-add-retries.spec.md @@ -0,0 +1,158 @@ +```ts +fn(['CallSaveManager', key]) + .options({ + retry: { + maxRetries: 5, + backoffFactor: 3 + } + }) + .input({...}) +``` + +for now not implemented as changing the ai calls to be sequentail rather than parallel (and adding small delay beetween ai calls) inside @save-call-manger has sovled the issue +(i think, lets see if issue comes up agian in future) + +retries are built into ai package but could be helpful to add as general reusable functioanlity to fn + +INSPIRATION (each one of tehse files also has tests that i could copy, just give credit at top of file): + +https://github.com/vercel/ai/blob/main/packages/ai/core/prompt/prepare-retries.ts + +```ts +import { InvalidArgumentError } from '../../errors/invalid-argument-error'; +import { + RetryFunction, + retryWithExponentialBackoff, +} from '../../util/retry-with-exponential-backoff'; + +/** + * Validate and prepare retries. + */ +export function prepareRetries({ + maxRetries, +}: { + maxRetries: number | undefined; +}): { + maxRetries: number; + retry: RetryFunction; +} { + if (maxRetries != null) { + if (!Number.isInteger(maxRetries)) { + throw new InvalidArgumentError({ + parameter: 'maxRetries', + value: maxRetries, + message: 'maxRetries must be an integer', + }); + } + + if (maxRetries < 0) { + throw new InvalidArgumentError({ + parameter: 'maxRetries', + value: maxRetries, + message: 'maxRetries must be >= 0', + }); + } + } + + const maxRetriesResult = maxRetries ?? 2; + + return { + maxRetries: maxRetriesResult, + retry: retryWithExponentialBackoff({ maxRetries: maxRetriesResult }), + }; +} +``` + +SOURCE 2: https://github.com/vercel/ai/blob/main/packages/ai/util/retry-with-exponential-backoff.ts + +how the factor works: + +1. Increase Retry Factor: Changing the exponential backoff factor from 2 to 5 could help, but: + With factor 2: intervals grow like 1s, 2s, 4s, 8s... + With factor 5: intervals grow much faster: 1s, 5s, 25s, 125s... + This gives Claude more time to recover between retries + +```ts +import { APICallError } from '@ai-sdk/provider'; +import { delay, getErrorMessage, isAbortError } from '@ai-sdk/provider-utils'; +import { RetryError } from './retry-error'; + +export type RetryFunction = ( + fn: () => PromiseLike +) => PromiseLike; + +/** +The `retryWithExponentialBackoff` strategy retries a failed API call with an exponential backoff. +You can configure the maximum number of retries, the initial delay, and the backoff factor. + */ +export const retryWithExponentialBackoff = + ({ + maxRetries = 2, + initialDelayInMs = 2000, + backoffFactor = 2, + } = {}): RetryFunction => + async (f: () => PromiseLike) => + _retryWithExponentialBackoff(f, { + maxRetries, + delayInMs: initialDelayInMs, + backoffFactor, + }); + +async function _retryWithExponentialBackoff( + f: () => PromiseLike, + { + maxRetries, + delayInMs, + backoffFactor, + }: { maxRetries: number; delayInMs: number; backoffFactor: number }, + errors: unknown[] = [] +): Promise { + try { + return await f(); + } catch (error) { + if (isAbortError(error)) { + throw error; // don't retry when the request was aborted + } + + if (maxRetries === 0) { + throw error; // don't wrap the error when retries are disabled + } + + const errorMessage = getErrorMessage(error); + const newErrors = [...errors, error]; + const tryNumber = newErrors.length; + + if (tryNumber > maxRetries) { + throw new RetryError({ + message: `Failed after ${tryNumber} attempts. Last error: ${errorMessage}`, + reason: 'maxRetriesExceeded', + errors: newErrors, + }); + } + + if ( + error instanceof Error && + APICallError.isInstance(error) && + error.isRetryable === true && + tryNumber <= maxRetries + ) { + await delay(delayInMs); + return _retryWithExponentialBackoff( + f, + { maxRetries, delayInMs: backoffFactor * delayInMs, backoffFactor }, + newErrors + ); + } + + if (tryNumber === 1) { + throw error; // don't wrap the error when a non-retryable error occurs on the first try + } + + throw new RetryError({ + message: `Failed after ${tryNumber} attempts with non-retryable error: '${errorMessage}'`, + reason: 'errorNotRetryable', + errors: newErrors, + }); + } +} +``` diff --git a/packages/fn/README.md b/packages/fn/README.md new file mode 100644 index 0000000..5d51df2 --- /dev/null +++ b/packages/fn/README.md @@ -0,0 +1,188 @@ +# @davstack/fn + +A tiny, transport-agnostic convention for server functions. Define your business logic +once as a plain, **directly-callable** async function with input/output validation and +middleware — then call it from anywhere (a route handler, a server action, a script, or +another fn). Attach a transport (tRPC, oRPC, HTTP) at the edge with a thin adapter, never +in the core. + +The core has **zero runtime dependencies** and never throws abstraction at you: a fn is +just a function you call with `{ input, ctx }`. + +```bash +npm install @davstack/fn zod +``` + +> `zod` is a peer dependency (v3 or v4). Schemas are optional. + +## Quick start + +```ts +import { createFn, FnError } from '@davstack/fn'; +import { z } from 'zod'; + +export const createChat = createFn({ + name: 'createChat', + inputSchema: z.object({ title: z.string() }), + handler: async ({ input, ctx }) => { + return ctx.db.chat.create({ data: { title: input.title } }); + }, +}); + +// Call it directly — same `{ input, ctx }` shape as the handler. +const chat = await createChat({ input: { title: 'Hello' }, ctx: { db } }); +``` + +A fn validates its input (applying zod defaults/transforms), runs its middleware, runs the +handler, then validates its output if an `outputSchema` is set. On any failure it **throws an +`FnError`** (see [Errors](#errors)). + +## Definition shape + +```ts +createFn({ + name: 'sendWelcomeText', // required — used in error traces & adapters + description: '...', // optional + tags: ['sms', 'credits'], // optional + inputSchema: z.object({ ... }), // optional + outputSchema: z.object({ ... }), // optional — validated on the way out + middleware: [/* fn-specific middleware */], // optional + handler: async ({ input, ctx }) => { ... }, +}); +``` + +`input` is typed from `inputSchema` (or `void` if omitted). The handler's return type (or +`outputSchema`) becomes the call's resolved type. + +## Shared context & base middleware — `initCreateFn` + +Declare your context type once and pre-attach base middleware with `initCreateFn`. Everything +built from it inherits that context type and middleware. + +```ts +import { initCreateFn, createMiddleware, FnError } from '@davstack/fn'; + +type PublicCtx = { db: Db; user?: { id: string } }; +type AuthedCtx = Required; + +const authMiddleware = createMiddleware(async ({ ctx, next }) => { + if (!ctx.user?.id) throw new FnError({ code: 'UNAUTHORIZED' }); + return next(); // short-circuits if you don't call it +}); + +export const createPublicFn = initCreateFn(); +export const createAuthedFn = initCreateFn([authMiddleware]); +``` + +Now `createAuthedFn(...)` produces fns whose `ctx` is typed `AuthedCtx`, with `authMiddleware` +always running first. + +## Middleware + +Middleware is an onion (Express/tRPC-style `next` chain). Each receives +`{ ctx, input, def, next }` and can run code before/after `next`, swap the context or input +(`next(newCtx, newInput)`), wrap it in `try/catch`, or short-circuit by not calling it. + +```ts +const timing = createMiddleware(async ({ def, next }) => { + const start = performance.now(); + const result = await next(); + console.log(`${def.name} took ${performance.now() - start}ms`); + return result; +}); +``` + +## Composition + +Because fns are just callables, compose them by calling one inside another's handler — pass +the same `ctx` through. Error traces accumulate across the call path (see below). + +```ts +export const sendWelcomeText = createAuthedFn({ + name: 'sendWelcomeText', + inputSchema: z.object({ chatId: z.string() }), + handler: async ({ input, ctx }) => { + await checkCredits({ input: { actionType: 'welcome' }, ctx }); + const text = await generateWelcomeText({ ctx }); + return sendSms({ input: { chatId: input.chatId, message: text }, ctx }); + }, +}); +``` + +## Errors + +Failures throw an `FnError` — a typed error with a tRPC-style `code`, structured `meta`, and a +`functionTrace` that accumulates the fn names along the call path (so a deep failure tells you +*which business operations* were in play, not just a file:line stack). + +```ts +import { FnError, isFnError } from '@davstack/fn'; + +throw new FnError({ code: 'INSUFFICIENT_CREDITS', message: 'Not enough credits' }); +``` + +Validation failures throw automatically with `code: 'INVALID_INPUT'` / `'INVALID_OUTPUT'` and +the zod error in `meta.zodErrors`. + +### Want a result instead of a throw? + +There's no `.safeCall`. Wrap the direct call with [`tryCatch`](../try-catch) from the +companion package: + +```ts +import { tryCatch } from '@davstack/try-catch'; +import type { FnError } from '@davstack/fn'; + +const { data, error } = await tryCatch(() => + createChat({ input: { title: 'Hello' }, ctx }) +); +if (error) return handle(error); // typed FnError +use(data); // narrowed to non-null +``` + +Use the thunk form (`() => fn(...)`) so a synchronous throw is caught too. + +## Transport adapters + +The core knows nothing about transports. Attach one at the edge: + +- **tRPC** — [`@davstack/fn-trpc`](../fn-trpc) turns a fn into a tRPC procedure via + `initProcedureFactory`. + +```ts +import { initTRPC } from '@trpc/server'; +import { initProcedureFactory } from '@davstack/fn-trpc'; + +const t = initTRPC.create(); +const fromFn = initProcedureFactory(t.procedure); + +export const appRouter = t.router({ + createChat: fromFn(createChat, 'mutation'), +}); +``` + +- **oRPC** — [`@davstack/fn-orpc`](../fn-orpc) turns a fn into an oRPC procedure via + `initProcedureFactory` (no query/mutation arg — chain `.route()` for OpenAPI). + +```ts +import { os } from '@orpc/server'; +import { initProcedureFactory } from '@davstack/fn-orpc'; + +const fromFn = initProcedureFactory(os); + +export const router = { + createChat: fromFn(createChat), +}; +``` + +## Companion packages + +| Package | What it does | +| --- | --- | +| [`@davstack/try-catch`](../try-catch) | `tryCatch(promiseOrThunk)` → `{ data, error }` (zero-dep) | +| [`@davstack/fn-trpc`](../fn-trpc) | tRPC adapter (`initProcedureFactory`) | +| [`@davstack/fn-orpc`](../fn-orpc) | oRPC adapter (`initProcedureFactory`) | + +## License + +MIT diff --git a/packages/fn/fn-api.idea.ts b/packages/fn/fn-api.idea.ts new file mode 100644 index 0000000..204c135 --- /dev/null +++ b/packages/fn/fn-api.idea.ts @@ -0,0 +1,174 @@ +// @ts-nocheck + +import { baseFn, FnError, redactSensitive } from '@ream/fn'; + +import { logger, redactSensitiveDataFromClaudeErrors } from '@api/lib/logger'; +import { enhance, prisma, type PrismaClient } from '@ream/db'; + +import { z, ZodType } from 'zod'; + +// const createServerFn = baseFn(key) +// .use(baseContextMiddleware) +// .use(loggingMiddleware) +// .use(authedMiddleware) +// .use(errorMiddleware); + +// const baseContextMiddleware = createMiddleware(({ ctx, next }) => +// next(createServiceCtx(ctx.user)) +// ); + + +// const errorMiddleware = createMiddleware(({ ctx, next }) => { +// // wrap function in error handling +// return next(ctx); +// }); + +// const loggingMiddleware = createMiddleware(({ ctx, next }) => +// // wrap function in logging +// return next(ctx); +// ); +// const authedMiddleware = createMiddleware(({ ctx, next }) => { +// if (!ctx.user.id) { +// throw new FnError({ +// code: 'UNAUTHORIZED', +// message: 'Unauthorized', +// }); +// } +// return next(ctx); +// }); + +export const createServiceCtx = ( + _user?: Partial & { db?: PrismaClient } +) => { + const user = { + id: _user?.id ?? '', + email: _user?.email ?? '', + role: 'USER' as const, + }; + + const db = _user?.db ?? enhance(prisma, { user }); + + return { db, user }; +}; + +export type User = { + id: string; + email: string; + role?: 'USER' | 'ADMIN'; +}; +export type ServerFnCtx = { + logger: Logger; + db: PrismaClient; + user?: User; +}; +export type ServerFnCtxAuthed = Required; + +export const createAuthedServerFn = createServerFn.use( + async ({ ctx, next }) => { + if (!ctx.user.id) { + throw new FnError({ + code: 'UNAUTHORIZED', + message: 'Unauthorized', + }); + } + return next(ctx); + } +); + +export const createPublicServerFn = createServerFn; + +// Easier to type, but less ideal: +// export const createAuthedServerFn = (key: string) => +// createServerFn(key).use(async ({ ctx, next }) => { +// if (!ctx.user.id) { +// throw new FnError({ +// code: 'UNAUTHORIZED', +// message: 'Unauthorized', +// }); +// } +// return next(ctx); +// }); + +// export const createPublicServerFn = (key: string) => +// createServerFn(key).use(async ({ ctx, next }) => { +// return next(ctx); +// }); + +// ===== Define Server Functions ===== + +// basic example: + +const createChat = createAuthedServerFn({ + name: 'createChat', + tags: ['chat'], + inputSchema: z.object({ + title: z.string(), + }), + handler: async ({ input, ctx }) => { + return ctx.db.chat.create({ + data: { + title: input.title, + }, + }); + }, +}); + +// nested example: + +const sendWelcomeText = createAuthedServerFn({ + name: 'sendWelcomeText', + tags: ['sms', 'credits'], + description: ` + - Ensures the user has enough credits + - Generates a personalized welcome text + - Sends the welcome text to the user + `, + inputSchema: z.object({ + chatId: z.string(), + }), + outputSchema: z.object({ + success: z.boolean(), + }), + handler: async ({ input, ctx }) => { + const canSend = checkCredits({ + ctx, + input: { + actionType: 'send-welcome-text', + }, + }); + + if (!canSend) { + throw new FnError({ + code: 'INSUFFICIENT_CREDITS', + message: 'Insufficient credits', + }); + } + + const personalizedWelcomeText = await generatePersonalizedWelcomeText({ + ctx, + }); + + const status = await sendSms({ + ctx, + input: { + phoneNumber: input.phoneNumber, + message: personalizedWelcomeText, + }, + }); + + return status; + }, +}); + +// ===== Use Server Functions ===== + +const + +const ctx = createServiceCtx(await getUser(cookies())); + +const chat = createChat({ + ctx, + input: { + title: 'Hello', + }, +}); \ No newline at end of file diff --git a/packages/fn/notes/design-decisions-and-vision.md b/packages/fn/notes/design-decisions-and-vision.md new file mode 100644 index 0000000..82b8cf8 --- /dev/null +++ b/packages/fn/notes/design-decisions-and-vision.md @@ -0,0 +1,362 @@ +# `@davstack/fn` — design decisions, evaluations & vision + +> A consolidated record of the design conversation about the future of this package: +> the guiding philosophy, every decision with its rationale and caveats, the research +> that informed them, and the deliberately-cautious near-term plan. +> +> Companion notes: `[eval-pipe-vs-middleware.md](./eval-pipe-vs-middleware.md)` (delete `pipe`), +> `[eval-fnerror.md](./eval-fnerror.md)` (keep `FnError`), +> `[research-orpc-internals.md](./research-orpc-internals.md)` (how hard owning HTTP is). +> Private research (kept out of this repo): `~/dev/output-schema-migration-notes.md`. + +--- + +## 1. What the package is + +`@davstack/fn` is a **transport-agnostic server-function convention**. You define a plain +function with metadata: + +```ts +createPublicServerFn({ + name, description?, tags?, + inputSchema?, outputSchema?, + route?: { path, method }, // (proposed) marks it as a public HTTP endpoint + handler: async ({ input, ctx }) => { ... }, +}) +``` + +The defining property — and the thing the user values most — is that **the fn is callable +with the exact same `{ input, ctx }` as its handler**. No builder chain; a flat `{}` config +object. This makes fns directly callable, portable, testable, and composable, independent of +any transport. + +Real consumption today: + +- **titanium** uses it via `initCreateFn([...middleware])` → `createPublicServerFn` / +`createAuthedServerFn`, with three middlewares (tracing, error-handling, auth). ~92 in-use +fns. Currently bridged into **tRPC** through `initProcedureFactory` (a ~256-line `router.ts` +with ~98 manual registrations). +- **aftrak** does NOT use `@davstack/fn`; it uses **oRPC** directly (15 procedures, all with +`.output()`). It is the reference for the oRPC + OpenAPI pattern. + +--- + +## 2. The guiding philosophy (the principle everything else follows from) + +**Ports & adapters.** Business logic lives in plain, portable, directly-callable functions. +Transport (tRPC / oRPC / HTTP) is a thin adapter bolted on at the edge — never the home of +the logic. + +The user articulated this sharply: they *dislike* the "server-fn wrapped in tRPC/oRPC" model +because it "feels like typing up all your business logic within a 3rd-party library." They +*prefer* directly-callable functions because they're portable (usable in tests, other +frameworks), and composable. This is the north star. + +Consequence: the package is really **two layers**, and they have very different risk profiles: + +- **Layer 1 — the directly-callable fn convention.** Good, safe, low-risk. This is your own +code in plain-function form. Not lock-in. +- **Layer 2 — the transport/codegen bridge.** Risky. Source of both the registration +boilerplate *and* the IDE lag. Must stay thin, optional, and ejectable. + +--- + +## 3. The cautionary tale & the test that keeps us honest + +The user was previously burned by `**@davstack/store`**: a custom-optimal solution that lost +to standard-but-AI-trained alternatives because of the compatibility/education tax. The fear +is repeating that — building a "framework" the world must adopt. + +**The ejectability test:** a *convention* is one you can throw away while keeping the durable +artifacts; a *framework* owns your control flow and can't be removed without a rewrite. + +This package passes the test **as long as the durable artifact is a standard** (OpenAPI) and +the durable logic is **your own plain functions**. The danger is only ever in Layer 2. + +A key clarification that resolved a lot of tension: there are two meanings of "standard." + +- ❌ *Evangelize a new governed spec the world must adopt* — this is the store trap. Avoid. +- ✅ *Adopt the minimal convergent shape everyone already uses* (input + output + handler + +name/description) — this is just naming the obvious. Safe. + +The fn shape is the second kind. So "make it a standard" is fine **if** it means "use the +convergent shape + lean on existing standards," and dangerous **if** it means "get others to +adopt open-fn and maintain governance/compat for external users." + +--- + +## 4. Decision log + +Each entry: **decision · rationale · caveats · status.** + +### D1 — Delete `pipe` + +- **Decision:** remove `src/pipe.ts`, drop its export, remove the stale `why-pipe-over-middleware.md`. +- **Rationale:** dead code inherited from `packages/store`. Unused by `createFn`, tests, or +README. Its one theoretical advantage (type-safe progressive context evolution) is +deliberately *not* used — context is declared upfront as one generic. The middleware array +is strictly more capable (it needs around/onion semantics + short-circuit that `pipe`'s +linear `reduce` can't express). The 2-line runtime is buried under ~4245 lines of overload +signatures — pure type-check-time noise and a contributor to IDE lag. +- **Caveat:** none. (Only reason to keep would be exposing a generic FP helper for consumers — +not fn-specific value.) +- **Status:** agreed. (See `eval-pipe-vs-middleware.md`.) + +### D2 — Keep `FnError` + +- **Decision:** retain `src/errors.ts`. +- **Rationale:** load-bearing and earns its ~150 lines. Gives typed error `code` +(tRPC-style), `functionTrace` accumulation across composed fns (semantic call path of fn +names — survives async boundaries where native stacks don't), idempotent `from()` (enhances +in place, preserves original cause/stack), `markAsReported()` dedup, and structured `meta` +(zod field errors for the `Result` error half). Plain `Error` would mean reimplementing a +worse subset. It's baked into the public `Result` type. +- **Caveat:** minor cleanups available (redundant `cause` field reassignment; +`getDefaultErrorMessage` only covers ~5/19 codes). +- **Status:** agreed — keep. (See `eval-fnerror.md`.) + +### D3 — Replace `.safeCall` with a standalone `tryCatch` + +- **Decision:** remove the `.safeCall` method from the fn object; ship a standalone +`tryCatch(promise)` util (in its own package) that wraps any promise into `{ data, error }`. + ```ts + type Result = { data: T; error: null } | { data: null; error: E }; + async function tryCatch(promise: Promise): Promise> { + try { return { data: await promise, error: null }; } + catch (error) { return { data: null, error: error as E }; } + } + ``` +- **Rationale:** makes the fn a *plain callable* (no methods bolted on) → maximally portable +and composable, aligning with the core philosophy. The util is generic (works on any +promise, not just fns) and uses the widely-recognized shape (good for the anti-store +"use familiar patterns" goal). Lighter core. +- **Caveats:** (1) type it `tryCatch(...)` at fn call sites to keep typed-error +branching. (2) `tryCatch(fn(args))` evaluates the promise eagerly, so errors thrown during +*argument construction* aren't caught — a non-issue for async fns (they reject, never throw +sync). +- **Status:** agreed; near-term. + +### D4 — Standard Schema v1 instead of zod under the hood + +- **Decision:** type the core's schema fields as `StandardSchemaV1` rather than zod-specific +types; validate via `schema['~standard'].validate(...)`; infer via +`StandardSchemaV1.InferInput/InferOutput`. +- **Rationale:** + - oRPC is itself built on Standard Schema, so `toOrpcRouter` passes schemas straight + through — native composition, zero impedance. (Standard Schema makes *more* sense for the + oRPC setup, not less.) + - Decouples the core from zod → users can bring zod / valibot / arktype. + - **Deletes the zod v3/v4 dual-support code** (`isZod4`, branched `zInfer`/`zInferInput`) — + so this is partly *subtraction*, not just refactor. + - Fixes the titanium **zod import-drift landmine** (`zod/v3` vs `zod` root on `zod@^3.25` + causing "two zod instances" errors) at the package boundary. +- **The decisive caveat:** Standard Schema standardizes *validation*, **not** JSON-Schema +generation. So the OpenAPI step needs a library-specific converter, registered with oRPC's +generator. **zod → `@orpc/zod`** is the most mature (zod v4 also has native +`z.toJSONSchema`). valibot/arktype converters exist but are less battle-tested. +- **Net pattern:** **Standard Schema as the *interface* (core, lib-agnostic), zod as the +*implementation* you actually write** (best OpenAPI conversion). The core never needs +zod-specific behavior; anything lib-specific lives in the adapter layer where knowing it's +zod is fine. +- **Status:** agreed; folds into the core subtraction. + +### D5 — Make `outputSchema` required when `route` is set + +- **Decision:** require `outputSchema` iff the fn has a `route` (i.e. is publicly exposed); +optional otherwise. +- **Rationale (this is load-bearing, not cosmetic):** + 1. **Cheaper types / fixes IDE lag.** The lag comes from output types inferred as + `Awaited>` *through* Kysely's heavy types, ×~98. Sourcing the type + from `outputSchema` (`InferOutput`) makes it an already-flat resolved type. This is the + enabling condition for a typed adapter without lag. + 2. **OpenAPI is impossible without it** — you can't serialize an inferred TS type to JSON + Schema at runtime; you need a declared output schema. + 3. **Runtime safety boundary** — catches handler bugs and stops leaking extra DB columns + (relevant to RLS/security work). + 4. **Smoke tests** get an assertion target. +- **Why `iff route` (not global):** kills the only weak case — void setter fns gaining nothing +but `z.void()` boilerplate. `route` becomes both the "expose" switch *and* the +"output required" trigger. Internal/void fns stay friction-free. +- **Caveat — validate at the boundary, not on direct calls:** parse output at the HTTP edge +(and always in dev to catch schema/handler drift); skip on internal direct calls so the +hot portable-call path stays zero-overhead. Drift maintenance (schema vs `selectAll()` as +columns change) is the real ongoing cost — supazod regen covers base tables; partial +selects/joins drift and need discipline. +- **Status:** agreed in principle; depends on adopting the `route` metadata. + +### D6 — Package split: dep-free core + separate adapter packages + +- **Decision:** core `@davstack/fn` carries **zero transport deps**. tRPC and oRPC adapters +live in separate packages. `tryCatch` in its own package too. +- **Rationale:** the live contradiction is a "transport-agnostic" core that hard-depends on +`@trpc/server` + `superjson` in `dependencies`. Splitting is pure hygiene and *reduces* +maintenance. It also makes each adapter individually optional/ejectable. +- **Caveat / scope discipline:** split the core out, but do **not** build a general "plugin +system"/ecosystem for hypothetical adapters — that's premature generalization (store trap). +Build exactly the adapters you need (tRPC for titanium today, oRPC for aftrak); let any +third adapter reveal the shared shape. +- **Status:** agreed; near-term (see §6 plan). + +### D7 — OpenAPI is the *boundary*; oRPC is the *implementation* + +- **Decision:** treat `openapi.json` as the contract/boundary. Whatever serves it (oRPC now) +is a swappable implementation detail. +- **Rationale:** this resolves the apparent "OpenAPI vs oRPC" conflict — they're different +layers. The client (orval-generated hooks) depends *only* on the spec, never imports oRPC. +So you can later swap oRPC → your own generator+handler with **zero downstream change**. +Independence is secured by the spec being the boundary — you get it *now* without owning the +generator now. +- **Caveat:** because the boundary is the spec, "use openapi" and "use the oRPC adapter" are +both true simultaneously. Don't agonize over the choice; it's reversible. +- **Status:** agreed as the principle. + +### D8 — Owning the HTTP layer: possible & cheap, but delegate for now + +- **Research verdict:** owning a standard-OpenAPI HTTP handler is a **~1-day, ~250–400 LOC** +job. Our use case (orval → plain JSON REST) maps to oRPC's `OpenAPIHandler` family, **not** +its `RPCHandler`/custom RPC protocol — and the RPC half (super-JSON typed codec for +Date/Map/Set/bigint/File, RPC wire format, RPC client) is the bulk of oRPC's size and is +**completely irrelevant** to us. MVP = match route (reuse `rou3` trie), parse JSON body + +path/query, validate via Standard Schema, call fn, validate output, map error→status +(~80 trivial LOC), return JSON; adapters ~30–50 LOC each. Long tail (bracket-notation nested +query params, files/multipart, SSE/streaming, exotic coercion) is all deferrable. +- **Decision:** **delegate to oRPC for now.** The user has no time to build/maintain HTTP. +The research was still worth it: it proves you *could* own it cheaply, which permanently +kills the "thin oRPC wrapper" anxiety. +- **Why "thin wrapper" doesn't apply anyway:** your value was never HTTP serving — it's the +fn-authoring layer, the internal/composition superset (oRPC has no concept of +"functions that exist but aren't exposed"), and the OpenAPI projection. The adapter is the +last ~5%. +- **Status:** delegate now; owning is a documented, deferred option. + +### D9 — A registry `{ key: fn }` as the single source of truth + +- **Decision:** collect fns into a registry object; everything is a projection of it. + ``` + registry { k: fn } ─→ toOrpcRouter(registry) ─→ oRPC serves HTTP + (ALL fns) │ └─→ oRPC emits openapi.json ─→ orval ─→ typed hooks + └─→ smokeTest(registry) ─→ loop, call, assert output + ``` +- **Rationale:** one source feeds router, spec, and smoke tests. **Registry = all fns +(superset); OpenAPI = the `route`d subset (projection).** Internal/composition fns live in +the registry and feed smoke tests but never get a `route`, so never hit the public spec — +the internal-layer value falls straight out of the design. Registry can be nested +(`{ user: { list, create } }`) for grouping → oRPC structure / OpenAPI tags; `route` drives +the actual HTTP path. +- **Caveat:** `toOrpcRouter` should be a **generic mapper**, not 98 hand-registrations — that +avoids replicating titanium's `router.ts` pain. +- **Status:** agreed as the target shape (deferred with the oRPC/openapi decision). + +### D10 — Codegen is an explicit command, NOT in the watch script + +- **Decision:** `pnpm gen:api` = registry → `openapi.json` → orval → hooks. Run on demand. +- **Rationale:** codegen-on-every-save is exactly the always-running magic machinery that +causes noise/lag — the thing being escaped. Contract changes are infrequent and intentional +(like migrations); handler-body edits (the common case) don't change the contract, so ~95% +of saves shouldn't trigger codegen. Predictable > automatic, matching the low-abstraction +values. Automate later only if manual runs become genuinely annoying (or have oRPC serve the +spec live at `/openapi.json` and point orval at the endpoint). +- **Status:** agreed (applies if/when the openapi/orval path is adopted). + +### D11 — Smoke tests from the registry + +- **Decision:** `smokeTest(registry)` loops every fn, calls it, asserts the result validates +against its `outputSchema`. +- **Rationale:** registry gives free enumeration; mandatory output schema gives the assertion +target. +- **Caveat (be honest about it):** valid inputs + a test ctx (db/user) are *not* free. +Auto-coverable: `z.void()`/no-input fns. Everything else needs a registered fixture +(e.g. an optional `examples`/`fixture` field on the fn, reusable as OpenAPI example +payloads). The runner must **log which fns it skipped** — never report green on partial +coverage. +- **Status:** desirable; deferred. + +### D12 — Naming: `open-fn` vs keep `@davstack/fn` — UNDECIDED + +- **Arguments for keeping `fn`:** it's accurate and humble; already published at +`@davstack/fn` v1.1.0 (renaming = churn); "open-X" implies an open *standard* with +governance, which is the framing to avoid. You can *describe* it as "an open, +transport-agnostic fn convention that compiles to OpenAPI" without renaming. +- **Arguments for `open-fn`:** evokes OpenAPI; signals the interoperable/standard intent; +brandable; with Standard Schema adoption the "open/interoperable" framing is more defensible. +- **Status:** **open / undecided.** Leaning cautious (keep `fn`) but not settled. + +--- + +## 5. The IDE-lag diagnosis (context for several decisions above) + +After running the tRPC-generation script on a titanium branch, the IDE began lagging badly. +Most likely cause: the generated tRPC `AppRouter` over ~98 procedures, each inferring its +output type *through* heavy Kysely query builders → tsserver type-instantiation blowup. +Secondary contributors: the unused 60-overload `pipe`, and zod v3/v4 dual inference. + +Mitigations (which several decisions encode): source output types from `outputSchema` instead +of inferring handler returns (D5); delete `pipe` (D1); move to Standard Schema (D4); prefer a +data artifact (`openapi.json`) over a giant generated *typed* router file — **codegen that +emits DATA is harmless; codegen that emits heavy TYPES is what lagged** (D7/D10). Confirm with +`tsc --generateTrace` if needed. + +--- + +## 6. The near-term plan (deliberately cautious) + +The user is intentionally limiting changes — **not yet committing** to going all-in on oRPC, +or on the OpenAPI/orval direction. So for now: + +1. **Move `tryCatch` to a separate package** and export it; **remove `.safeCall`** (D3). +2. **Swap to Standard Schema v1** over zod in the core (D4). +3. **Move `@trpc/server` + `superjson` deps to a separate package** — but **keep tRPC + compatibility** (titanium still relies on it). I.e. move the **tRPC plugin/adapter + (`initProcedureFactory`) into its own package**, don't delete it (D6). +4. **Create a separate oRPC adapter package** for the aftrak codebase (D6). +5. **Delete `pipe`** (D1) and **keep `FnError`** (D2). + +Explicitly **deferred / not doing now:** owning the HTTP layer (D8), the registry+codegen+ +orval pipeline (D9/D10), smoke tests (D11), making `outputSchema` mandatory across titanium +(D5 — the migration), and the final naming call (D12). + +> **Implementation status (branch `fn-near-term-plan`).** Done, in this order: +> +> 1. ✅ **Deleted `pipe`** (D1) — removed `src/pipe.ts` + its export + the stale +> `why-pipe-over-middleware.md` note. +> 2. ✅ **Extracted `tryCatch` → new `@davstack/try-catch`** (zero-dep) and **removed +> `.safeCall`** (D3). Also removed the now-dead `Result` type and `withSafeResultFormatter` +> middleware from core. fn tests that used `.safeCall` now assert on the thrown `FnError`. +> 3. ✅ **Extracted the tRPC adapter → new `@davstack/fn-trpc`** (D6); dropped `@trpc/server` +> **and** `superjson` from core deps (`superjson` was unused everywhere — dropped, not +> moved). **Core now has zero runtime dependencies.** +> +> **Paused here, before** the **oRPC adapter package** (D6, item 4) — by request, pending the +> decision on whether to go all-in on oRPC/OpenAPI. Also **not done this round:** the Standard +> Schema v1 swap (D4) — still zod-coupled for now. `FnError` kept (D2). fn bumped to a major +> (`2.0.0`) via changeset; the two new packages start at `0.1.0`. + +This keeps every change either **pure subtraction** or **dependency-relocation** — no bets on +the uncertain Layer-2 direction. The architecture stays open: adopting oRPC/OpenAPI later is a +clean, additive step because the boundary (the spec) and the source of truth (plain fns) +don't change. + +> **Don't forget the docs.** Several near-term changes are breaking and must be reflected in +> the `README.md` (and `CHANGELOG.md` / a version bump): `.safeCall` removed in favour of the +> standalone `tryCatch`, the Standard Schema swap (no longer zod-coupled), and the tRPC/oRPC +> adapters moving to separate packages (new install + import paths). Update docs as part of +> the same change, not after — stale examples are how a "simple" package starts feeling +> untrustworthy. + +--- + +## 7. Supporting research (summaries) + +**Output-schema migration effort** (private notes at `~/dev/output-schema-migration-notes.md`): +aftrak already 15/15 with output schemas. titanium ~~92 fns, ~49 already have `outputSchema` +(~~53%), ~43 missing. supazod `RowSchema` reuse + `z.coerce.date()` already solves the +date-column killer; `.pick()`/`.extend()` patterns already in production. ~25 of the 43 are +trivial/codemod-able, ~12–16 need hand-crafting (chart/stats/nested). **Total ~1.5–3 dev-days.** +Risks: zod import drift (v3 vs root), runtime parse cost on hot list/live endpoints, loosely +-typed Json columns, void-fn boilerplate. + +**oRPC internals** (`research-orpc-internals.md`): owning standard-OpenAPI HTTP is a ~1-day / +250–400 LOC MVP; the hard, large parts of oRPC are the RPC protocol + exotic serializer, none +of which our plain-JSON use case needs. The OpenAPI spec generator (`@orpc/openapi`, ~880 LOC) +is fully decoupled from serving — reuse it build-time-only, or own a lean zod-only generator +later. \ No newline at end of file diff --git a/packages/fn/notes/eval-fnerror.md b/packages/fn/notes/eval-fnerror.md new file mode 100644 index 0000000..ad603fe --- /dev/null +++ b/packages/fn/notes/eval-fnerror.md @@ -0,0 +1,65 @@ +# Evaluation: why `FnError`? Is it needed? (verdict: yes — keep it) + +> Analysis of `src/errors.ts` and how it's used. Opposite conclusion to +> [`eval-pipe-vs-middleware.md`](./eval-pipe-vs-middleware.md): unlike `pipe`, `FnError` +> earns its place. + +## TL;DR + +**Keep `FnError`.** It's load-bearing (used by every validation path, the `Result`/`safeCall` +type, and the composition story) and gives you things plain `Error` can't, cheaply (~150 +lines). It's the core of the package's value prop, not redundant inheritance. + +## What it buys you over plain `Error` + +1. **Typed error `code`** (tRPC-style: `UNAUTHORIZED`, `NOT_FOUND`, `INVALID_INPUT`, …). + Lets `.safeCall()` consumers and HTTP/tRPC layers branch on the *kind* of failure without + string-matching messages. Plain `Error` has nothing here. + +2. **`functionTrace` across composed fns** — the headline feature (tested in + `test/error-reporting.test.ts:156` and `test/tracing.test.ts`). As an error bubbles + through nested fns, `enhanceError` does `trace.unshift(def.name)`, producing + `[outerFn, middleFn, innerFn]` — a *semantic* call path of your fn names/tags. A native + stack trace gives file:line and gets mangled across async boundaries; it won't tell you + which business operations were in play. + +3. **Idempotent `from()`** — if the cause is already an `FnError`, it *enhances in place* + rather than re-wrapping. This is exactly the fix for the `improvements.md` complaint + ("we recreate errors in the wrapper, so we lose the original stack trace and get a + loooong message"). Original `cause` + stack are preserved while the trace accumulates. + +4. **`markAsReported()` / `_reported`** — dedup flag so a nested error isn't logged 3× as it + bubbles through 3 fns (tested at `test/error-reporting.test.ts:63`). Plain `Error` can't + carry this cleanly. + +5. **Structured `meta`** (e.g. `zodErrors`, `input`) — `withInputValidation` / + `withOutputValidation` attach Zod's `.flatten()`, so `safeCall` callers get field-level + validation detail. This is the `error` half of the `{ data, error }` `Result`. + +## Is it strictly needed? + +You *could* throw plain `Error`s — but the moment you want codes, validation detail in +`safeCall`, the cross-fn trace, or log dedup, you'd be reimplementing a worse subset of +exactly this. Not redundant the way `pipe` is. + +`Result` itself is typed as `{ data: T; error: null } | { data: null; error: FnError | Error }`, +so `FnError` is baked into the public surface. + +## Why it's here (vs `pipe`) + +`pipe` is dead inheritance from `packages/store`. `FnError` is the core of the package's +value prop — typed/validated errors with a composition-aware trace, framework-agnostic +(works in tRPC, server actions, and plain direct calls). The `improvements.md` notes +literally list the error problems (obfuscated messages, lost stack traces) that `FnError` +is the solution to. + +## Optional cleanups (not blockers) + +- `cause?: unknown` is redeclared as an instance field and reassigned, even though + `super(message, { cause })` already sets `this.cause` (ES2022). Slightly redundant. +- `getDefaultErrorMessage` only handles ~5 of the ~19 codes; the rest fall through to + `Error: ${code}`. Either fill them in or drop the switch and inline. + +## Conclusion + +**Keep `FnError`.** Delete `pipe`, keep this. diff --git a/packages/fn/notes/eval-pipe-vs-middleware.md b/packages/fn/notes/eval-pipe-vs-middleware.md new file mode 100644 index 0000000..fb4578e --- /dev/null +++ b/packages/fn/notes/eval-pipe-vs-middleware.md @@ -0,0 +1,82 @@ +# Evaluation: is `pipe` worth keeping? (verdict: no — delete it) + +> Analysis of `src/pipe.ts` against what the package actually does. +> See also the (now stale) design note [`why-pipe-over-middleware.md`](./why-pipe-over-middleware.md), +> whose conclusion the shipped code contradicts. + +## TL;DR + +**Delete `pipe`.** It's dead code inherited from `packages/store`, it's unused, and for +this package's needs the middleware array is *strictly more capable*. The one advantage +`pipe` has (type-safe progressive context evolution) is something this package deliberately +does **not** do. + +## What `pipe` actually is + +`src/pipe.ts` is 4247 lines, but the runtime is **2 lines** (the last ones in the file): + +```ts +export function pipe(x: any, ...fns: any[]) { + return fns.reduce((y: any, fn) => fn(y), x); +} +``` + +The other ~4245 lines are pure TypeScript overload signatures (one per arity, +`pipe(x)` … `pipe(x, f1, …, f40)`) so chained types infer. They're erased at build — +**zero bundle/runtime cost** (package is `sideEffects: false`, tree-shakeable). The real +cost is type-check time, file noise, and maintenance. + +## Is it used? + +No. `pipe` is exported from `index.ts` but: + +- **not** used by `createFn` (which runs on the middleware array + `executeMiddleware`), +- **not** referenced in any test, +- **not** referenced in the README. + +It's orphaned — it came across when this code lived in `packages/store`. + +## Does `pipe` give any benefit here? No. + +### 1. Its one advantage is unused + +`why-pipe-over-middleware.md` argued `pipe` wins on **type-safe progressive context +evolution** (middleware A adds `user`, so B + the handler then see `user` typed). But this +package punts on that entirely: + +- `middleware?: Middleware[]` — the array is typed `any`; context changes are **not** + tracked through the chain. +- Context is declared **upfront** as one generic: `initCreateFn([authMw])`. + In `test/composition.test.ts`, `ctx.user` is `{ id: string }` because the generic + *asserted* `AuthedServerFnCtx`, not because `authMw` narrowed it. + +So the design sidesteps the "type hell" the note worried about by just naming the full +context type upfront. `pipe`'s only edge is therefore moot. + +### 2. `pipe` is strictly *less* expressive for this use case + +`pipe` is a linear `reduce`: value out → value in, every step runs, forward only. But all +four built-in behaviors need *around* ("onion") semantics that `pipe` can't express: + +| Built-in middleware | Needs… | `pipe` can do it? | +| ------------------------- | --------------------------------------- | ----------------- | +| `withThrowingErrorHandler`| `try { await next() } catch` downstream | ❌ can't catch downstream | +| `withSafeResultFormatter` | run after `next`, reshape to `{data,error}` | ❌ no "after" hook | +| `withOutputValidation` | validate the *result* of `next` | ❌ | +| auth middleware | **short-circuit** (don't call `next`) | ❌ pipe always runs every step | + +Plus every middleware receives `{ ctx, input, def, next }` together; a `pipe` step only +gets the single piped value, so it can't see `def`/metadata or swap `input` + `ctx` +independently. + +## Conclusion + +The `next`-chain (Express/tRPC style) is the right primitive, and it's what already shipped. +`pipe` would be a downgrade: its theoretical edge is unused, and it loses the +around/short-circuit semantics the package relies on. + +**Action:** delete `src/pipe.ts`, remove `export * from './pipe'` from `index.ts`, and remove +the stale `why-pipe-over-middleware.md` note (the code went the other way). + +The only reason to keep `pipe` would be exposing it as a generic FP helper for consumers' +own handler code — but nothing here uses it, and that's not fn-specific value. diff --git a/packages/fn/notes/improvements.md b/packages/fn/notes/improvements.md new file mode 100644 index 0000000..937fa54 --- /dev/null +++ b/packages/fn/notes/improvements.md @@ -0,0 +1,316 @@ +Problem: Sentry not working + +- currently sentry not across express + railyway and next+vercel, so commmented out for now (and lots of ugly code) +- if we always passed logger to ctx, then we could inject the logger alongside the user ctx eg createNextContext createExpressContext +- PLAN: establish new pattern where logger is always passed to CTX + +Problem: Obfuscated error messages + +- We recreate errors in the wrapper, so we lose the original stack trace and get a loooong error message +- We should alawys keep logger.error first param as the original error, to avoid info loss + +Small Improvements: + +- replace "wrapper" with just middleware +- establish naming / meta pattern + +New API Design: + +Change Names: + +- baseFn -> createFn +- authedService -> createAuthedFn +- publicService -> createPublicFn + +So theres a bunch of complexity around managing the builder method and the middlware but i realised that perhaps its overkill and redudant because my existing impelemtnation (see attached doc of fn inside other codebase) was casting the type anyway so it was pretty pointless. + +We want to keep thigns as LIGHT and SIMPLE as possible to minimize surface area, maintance overhead, bugs, cognitive load, etc. + + + +```ts +// lib/create-server-fn.ts + +export type User = { + id: string; + email: string; + role: 'USER' | 'ADMIN'; +}; +export type ServerFnCtx = { + logger: Logger; + db: PrismaClient; + user?: User; +}; +export type ServerFnCtxAuthed = Required; + +function withBaseContext(fn: FnHandler) { + return (opts: { ctx: unknown; input: unknown }) => { + const user = { id: '' }; + return fn({ + input: opts.input, + ctx: { + user, + db: enhance(prisma, { user }), + logger: createLogger({ + level: 'info', + format: format.json(), + }), + }, + }); + }; +} + +function withLoggingMiddleware(fn: FnHandler) { + return (opts: { ctx: unknown; input: unknown }) => { + const logger = opts.ctx.logger; + logger.info('-> fn called', { + fn: opts.ctx.fn.name, + input: opts.input, + ctx: opts.ctx, + }); + const result = await fn(opts); + logger.info('<- fn result', { + fn: opts.ctx.fn.name, + result, + }); + return result; + }; +} + +export const createPublicServerFn = (def: FnDef) => { + return createFn({ + ...def, + handler: pipe( + withBaseContext, + withLoggingMiddleware, + withErrorHandling, + def.handler + ), + }); +}; + +export const createAuthedServerFn = (def: FnDef) => { + return createFn({ + ...def, + handler: pipe( + withBaseContext, + withLoggingMiddleware, + withErrorHandling, + def.handler + ), + }); +}; + +export function createCtx() { + const user = getUserFromCookie(); + return { + user, + db: enhance(prisma, { user }), + logger: createLogger({ + level: 'info', + format: format.json(), + }), + }; +} +``` + +and then here is more examples of the API: + +## Define Functions + +```ts +// server/chat + +const createChat = createAuthedServerFn({ + name: 'createChat', + tags: ['chat'], + inputSchema: z.object({ + title: z.string(), + }), + handler: async ({ input, ctx }) => { + return ctx.db.chat.create({ + data: { + title: input.title, + }, + }); + }, +}); +``` + +```ts +// this is wawy too verbose, the other design is better +const createChat = serverFn('createChat') + .input( + z.object({ + title: z.string(), + }) + ) + .output( + z.object({ + chat: z.object({ + id: z.string(), + title: z.string(), + }), + }) + ) + .mutation(async ({ input, ctx }) => { + return ctx.db.chat.create({ + data: { + title: input.title, + }, + }); + }); +``` + +## Usage: Inside nested functions + +```ts +const sendWelcomeText = createAuthedServerFn({ + name: 'sendWelcomeText', + tags: ['sms', 'credits'], + description: ` + - Ensures the user has enough credits + - Generates a personalized welcome text + - Sends the welcome text to the user + `, + inputSchema: z.object({ + chatId: z.string(), + }), + outputSchema: z.object({ + success: z.boolean(), + }), + handler: async ({ input, ctx }) => { + const canSend = checkCredits({ + ctx, + input: { + actionType: 'send-welcome-text', + }, + }); + + if (!canSend) { + throw new FnError({ + code: 'INSUFFICIENT_CREDITS', + message: 'Insufficient credits', + }); + } + + const personalizedWelcomeText = await generatePersonalizedWelcomeText({ + ctx, + }); + + const status = await sendSms({ + ctx, + input: { + phoneNumber: input.phoneNumber, + message: personalizedWelcomeText, + }, + }); + + return status; + }, +}); +``` + +### Usage: TRPC + +```ts +// router.ts + +const createFnProcedure = initCreateFnProcedure({ createContext }); + +export const appRouter = createTRPCRouter({ + chat: { + create: createFnProcedure(createChat, 'mutation'), + get: createFnProcedure(getChat, 'query'), + }, +}); +``` + +```ts +// app/api/[...trpc].ts + +//psuedo code - the point is that we need to create the CTX here and pass it down to the handler +export { GET, POST } = createTRPCRequest(() => { + const ctx = createCtx(); + handleRequest(ctx); +}); +``` + +```ts +// app/chat/page.tsx +const ChatPage = () => { + const { mutate } = api.chat.create.useMutation(); + const { data } = api.chat.get.useQuery(); +}; +``` + +### Usage: Server Actions + +Using Next.js Server Actions. Particularly useful for working with FILE UPLOADS or STREAMING responses - as trpc isn't a good fit for these. + +```ts +// actions.ts +'use server'; + +const createFnAction = initCreateFnAction({ createCtx }); + +export const createChatAction = createFnAction(createChat); +export const uploadFileAction = createFnAction(uploadFile); +``` + +```ts +// app/chat/page.tsx + +import { useMutation } from '@tanstack/react-query'; + +const ChatPage = () => { + const { mutateAsync } = useMutation({ + mutationFn: uploadFileAction, + }); +}; +``` + +### Usage: LLM tools (FUTURE - ignore for now) + +Using the AI SDK `tool` function. + +```ts +// tools.ts +const createFnTool = fnToolOptions({ createCtx }); + +export const tools = { + createChat: createFnTool(createChat), + uploadFile: createFnTool(uploadFile), +}; + +// or maybe this is better (so that we can use tool directly) +// im trying to think of how to remove the dependency on ai sdk +const fnToolOptions = initFnToolOptions({ createCtx }); + +export const tools = { + createChat: tool(fnToolOptions(createChat)), + uploadFile: tool(fnToolOptions(uploadFile)), +}; + +// or maybe even: +export const createChatTool = tool(fnToolOptions(createChat)); +export const uploadFileTool = tool(fnToolOptions(uploadFile)); +export const tools = { createChatTool, uploadFileTool }; +``` + +### Usage: trigger.dev (FUTURE - ignore for now) + +```ts +// tasks.ts +const createFnTask = initCreateFnTask({ createCtx }); + +export const createChatTask = createFnTask(createChat); +export const uploadFileTask = createFnTask(uploadFile); + +// or maybe this is better (so that we can use task directly) +// im trying to think of how to remove the dependency on trigger.dev +const fnTaskOptions = initFnTaskOptions({ createCtx }); + +export const createChatTask = task(fnTaskOptions(createChat)); +export const uploadFileTask = task(fnTaskOptions(uploadFile)); +``` diff --git a/packages/fn/notes/research-orpc-internals.md b/packages/fn/notes/research-orpc-internals.md new file mode 100644 index 0000000..e8e3b10 --- /dev/null +++ b/packages/fn/notes/research-orpc-internals.md @@ -0,0 +1,430 @@ +# oRPC internals: how hard is it to OWN the HTTP-serving layer? + +Research target: `@orpc/server`, `@orpc/openapi`, `@orpc/openapi-client`, `@orpc/client`, +`@orpc/contract`, `@orpc/standard-server*` at **v1.14.6**. + +Sources read (real dist): +- `aftrak/web/node_modules/.pnpm/@orpc+server@1.14.6_.../dist/...` (server core + adapters) +- `aftrak/web/node_modules/.pnpm/@orpc+standard-server@1.14.6/.../dist/index.mjs` +- `aftrak/web/node_modules/.pnpm/@orpc+standard-server-fetch@1.14.6/.../dist/index.mjs` +- `aftrak/web/node_modules/.pnpm/@orpc+client@1.14.6_.../dist/shared/client.D9eWXdBV.mjs` +- `aftrak/web/node_modules/.pnpm/@orpc+contract@1.14.6_.../dist/...` +- `@orpc/openapi@1.14.6` + `@orpc/openapi-client@1.14.6` (downloaded via `npm pack`, since aftrak + only installs `client`/`server`/`tanstack-query`, NOT the openapi packages). + +--- + +## TL;DR verdict + +**Owning a minimal standard-OpenAPI HTTP handler is genuinely easy — roughly a day, ~250-400 LOC.** +Our use case (orval-generated hooks → plain JSON REST endpoints) deliberately sidesteps almost +everything that makes oRPC's serving layer big. + +The bulk of oRPC's serving complexity exists to support **its own RPC wire protocol** (`RPCHandler`, +super-JSON-style typed codec, RPC client) and **edge features** (file uploads, SSE/event-iterators, +"detailed" input/output structures, bracket-notation nested-query encoding, lazy/contract-first +routers, hibernation, plugins, OTel spans). **None of those are on the critical path for plain JSON +in / plain JSON out.** + +The one piece that is non-trivial and worth keeping an eye on is the **OpenAPI spec generator** +(`@orpc/openapi`, ~880 LOC of Standard-Schema→JSON-Schema massaging). But that is *decoupled* from +serving — you can use one without the other. And orval needs a spec from us regardless of how we +serve. + +Recommendation: **own the handler, strongly consider owning the spec-gen too** (or use a thin +schema-library-native converter like `zod-to-json-schema`), and **do not adopt oRPC's RPC stack at +all** for the orval path. See the effort table at the end. + +--- + +## 1. Layer map — which layers matter for us + +oRPC ships two parallel "handler" families on top of one shared engine. + +``` + @orpc/contract (route config, ORPCError, defaults, Standard-Schema glue) + | + ┌──────────────────────────── @orpc/server (StandardHandler engine: match→decode→validate→call→encode) ───────────────────────────┐ + | | + | RPCHandler (server/dist/...) OpenAPIHandler (@orpc/openapi) | + | ── custom RPC wire protocol ── standard REST: rou3 path matching, JSON body, | + | ── StandardRPCCodec + super-JSON serializer ── query/path params, bracket-notation, OpenAPI status codes | + | ── pairs with RPCLink (the @orpc RPC client) ── StandardOpenAPICodec + StandardOpenAPISerializer | + | | + └─ adapters: fetch / node / aws-lambda / fastify / bun-ws / ws / crossws / message-port (Request/Response ↔ "standard" request) ────┘ + | + @orpc/standard-server (transport-neutral helpers: SSE encode/decode, content-disposition, header merge) + @orpc/standard-server-fetch / -node (Request/Response ↔ standard-request adapters) +``` + +| Layer | Package / file | Relevant to us? | +|---|---|---| +| **`OpenAPIHandler`** (standard REST) | `@orpc/openapi` `dist/shared/openapi.BB-W-NKv.mjs` (~205 LOC) | **YES** — this is the match we'd replicate | +| `RPCHandler` (custom RPC protocol) | `@orpc/server` core | **NO** — only needed by the oRPC RPC client; orval doesn't use it | +| `StandardHandler` engine | `@orpc/server` `dist/shared/server.ZxHCEN1h.mjs` (~226 LOC) | **YES (concept)** — the lifecycle we'd reimplement (small) | +| OpenAPI spec generator | `@orpc/openapi` `dist/shared/openapi.BwdtJjDu.mjs` (~880 LOC) | **YES** — orval needs a spec; decoupled from serving | +| `StandardOpenAPISerializer` (JSON + bracket) | `@orpc/openapi-client` `dist/shared/openapi-client.B2Q9qU5m.mjs` (~300 LOC) | **PARTLY** — JSON codec yes, bracket-notation only for nested query/form | +| `StandardBracketNotationSerializer` | `@orpc/openapi-client` `dist/shared/openapi-client.t9fCAe3x.mjs` (~146 LOC) | **MOSTLY NO** — only for nested objects in query/form | +| fetch adapter | `@orpc/server` `dist/adapters/fetch/index.mjs` (~167 LOC) | **YES (concept)** — trivial Request↔standard glue | +| `ORPCError` + status table | `@orpc/client` `dist/shared/client.D9eWXdBV.mjs` | **YES** — the error→status mapping, ~80 LOC, trivial to copy | +| RPC super-JSON serializer | `@orpc/client` RPC codec | **NO** — RPC protocol only | +| event-iterator / SSE | `@orpc/standard-server` `dist/index.mjs` (~268 LOC) | **NO** (MVP) — only if we want streaming | +| plugins, hibernation, batch, peer, ws | various | **NO** | + +**Conclusion for the framing question:** our client is orval-generated hooks hitting a standard +OpenAPI/REST endpoint. That maps to **`OpenAPIHandler` + the OpenAPI spec generator**, and explicitly +**NOT** to `RPCHandler` / `RPCLink` / the super-JSON RPC codec. The entire RPC half of oRPC is +irrelevant to us. + +--- + +## 2. Request lifecycle for the OpenAPI handler (end to end) + +The engine lives in `StandardHandler.handle()` +(`@orpc/server/dist/shared/server.ZxHCEN1h.mjs`, lines ~33-112). `StandardOpenAPIHandler` just +plugs in an OpenAPI-flavoured matcher + codec: + +```js +// @orpc/openapi/dist/shared/openapi.BB-W-NKv.mjs:193 +class StandardOpenAPIHandler extends StandardHandler { + constructor(router, options) { + const jsonSerializer = new StandardOpenAPIJsonSerializer(options); + const bracketNotationSerializer = new StandardBracketNotationSerializer(options); + const serializer = new StandardOpenAPISerializer(jsonSerializer, bracketNotationSerializer); + const matcher = new StandardOpenAPIMatcher(options); + const codec = new StandardOpenAPICodec(serializer, options); + super(router, matcher, codec, options); + } +} +``` + +The lifecycle (stripped of OTel spans / interceptors): + +1. **Prefix check** — bail with `{matched:false}` if pathname doesn't start with the mount prefix. +2. **Route match** — `matcher.match(method, "/")`. +3. **Decode input** — `codec.decode(request, params, procedure)`. (`step = "decode_input"`) +4. Wrap input in a span if it's an async iterator (irrelevant to us). +5. **Build procedure client** and **call it** — `client(input, {signal, lastEventId})` + (`step = "call_procedure"`). Input/output validation happens *inside* this client. +6. **Encode output** — `codec.encode(output, procedure)` → `{status, headers, body}`. +7. **Error path** — any throw is caught; if it happened during `decode_input` and isn't already an + `ORPCError`, it becomes `BAD_REQUEST` (400); otherwise `toORPCError(e)`. Then + `codec.encodeError(error)`. + +```js +// StandardHandler.handle() — error mapping excerpt +const error = step === "decode_input" && !(e instanceof ORPCError) + ? new ORPCError("BAD_REQUEST", { message: "Malformed request. ...", cause: e }) + : toORPCError(e); +const response = this.codec.encodeError(error); +return { matched: true, response }; +``` + +### 2a. Route matching — `StandardOpenAPIMatcher` +Uses the tiny **`rou3`** trie router (external dep). Patterns are translated from OpenAPI `{param}` +syntax to rou3 syntax: + +```js +// openapi.BB-W-NKv.mjs:113 +function toRou3Pattern(path) { + return standardizeHTTPPath(path) + .replace(/\/\{\+([^}]+)\}/g, "/**:$1") // {+rest} -> wildcard + .replace(/\/\{([^}]+)\}/g, "/:$1"); // {id} -> :id +} +``` +Match returns `{path, procedure, params}`; params are URI-decoded +(`decodeParams` → `tryDecodeURIComponent`). There's also lazy/contract-first router resolution +(`pendingRouters`, `unlazy`) — **we don't need lazy routers.** + +### 2b. Input decoding — `StandardOpenAPICodec.decode` +For the default **"compact"** input structure (the only one we'd use): + +```js +// openapi.BB-W-NKv.mjs:16 +async decode(request, params, procedure) { + const inputStructure = ...; // "compact" by default + if (inputStructure === "compact") { + const data = request.method === "GET" + ? this.serializer.deserialize(request.url.searchParams) // query → object + : this.serializer.deserialize(await request.body()); // JSON body → object + if (data === void 0) return params; + if (isObject(data)) return { ...params, ...data }; // merge path params + return data; + } + // "detailed": returns { params, query, headers, body } separately +} +``` + +So for a `POST` with a JSON body and a `{id}` path param, the handler input is +`{ ...pathParams, ...jsonBody }`. For `GET`, it's `{ ...pathParams, ...queryParams }`. +`request.body()` is the standard-server body parser +(`@orpc/standard-server-fetch` `toStandardBody`) which content-type-switches between JSON / FormData / +URLSearchParams / SSE / Blob. + +### 2c. Input validation +Inside the procedure client (`@orpc/server/dist/shared/server.DEBcqOjg.mjs:152`): + +```js +async function validateInput(procedure, input) { + const schema = procedure["~orpc"].inputSchema; + if (!schema) return input; + const result = await schema["~standard"].validate(input); // Standard Schema! + if (result.issues) { + throw new ORPCError("BAD_REQUEST", { + message: "Input validation failed", + data: { issues: result.issues }, + cause: new ValidationError({ ... issues: result.issues, data: input }), + }); + } + return result.value; +} +``` +Note: validation is via the **Standard Schema** `~standard.validate` contract — exactly what we +already plan to use. Input failure → `BAD_REQUEST` (400) with `data.issues` carrying the raw issues. + +### 2d. Invoke handler / output validation +`createProcedureClient` runs middlewares (we have none), calls the handler, then `validateOutput`: + +```js +async function validateOutput(procedure, output) { + const schema = procedure["~orpc"].outputSchema; + if (!schema) return output; + const result = await schema["~standard"].validate(output); + if (result.issues) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { ... }); // output fail → 500 + } + return result.value; +} +``` + +### 2e. Output serialization + response building +```js +// openapi.BB-W-NKv.mjs:48 (compact) +encode(output, procedure) { + const successStatus = ...; // 200 default + return { status: successStatus, headers: {}, body: this.serializer.serialize(output) }; +} +encodeError(error) { + return { status: error.status, headers: {}, + body: this.serializer.serialize(error.toJSON(), { outputFormat: "plain" }) }; +} +``` +The `{status, headers, body}` "standard response" is then turned into a real `Response` by the +adapter (§6). + +--- + +## 3. The genuinely hard parts — and do WE need them? + +| oRPC machinery | What it does | Where | Do WE need it (plain JSON OpenAPI + orval)? | +|---|---|---|---| +| **super-JSON RPC codec** (Date/Map/Set/bigint/File round-trip with a `meta` side-channel) | Lossless typed serialization for the **RPC** protocol | `@orpc/client` RPC codec (NOT in openapi) | **NO.** This is the RPC protocol. Standard OpenAPI uses plain JSON. | +| **`StandardOpenAPIJsonSerializer`** (one-way JSON normalize: Date→ISO, Set→array, Map→entries, bigint/URL/RegExp→string, NaN→null, Blob detect) | Down-converts rich JS values to JSON-safe values **for output** | `openapi-client.B2Q9qU5m.mjs:8` | **Barely.** ~45 LOC, trivial to copy if our handlers ever return `Date`/`Set`. For pure JSON in/out you can even use `JSON.stringify`. Keep as a tiny optional helper. | +| **Bracket-notation encode/decode** (`a[b][0]=x` ↔ nested object/array) | Encodes nested objects/arrays into flat query/form keys, decodes back | `openapi-client.t9fCAe3x.mjs` (~146 LOC) | **MOSTLY NO.** Only matters for **nested objects in query strings or form fields**. If our query params are flat scalars (the common REST case), `URLSearchParams` is enough. This is the single fiddliest file (push-style `a[]=` arrays, array→object promotion, escaping). Skip until a user actually nests query objects. | +| **String→typed coercion of params** | `"123"`→`123`, `"true"`→`true` for path/query | **Not done by oRPC core** — see note below | **YES eventually, but it's on the schema side.** The bracket/JSON deserializers return **strings**. oRPC relies on the *schema converter / schema library* to coerce (e.g. zod's coercion, or oRPC's experimental input-coercion plugin). For us: use `z.coerce.*` (or valibot equivalents) in `inputSchema`, OR add a coercion pass. **MVP can punt** if path/query inputs are already strings or you coerce in schema. | +| **multipart / file uploads** | `File`/`Blob` in request/response, content-disposition | `standard-server` + serializers | **NO (MVP).** Defer. Long-tail only if you serve binary. | +| **streaming / event-iterator / SSE** | Async-generator handlers → `text/event-stream`, keepalive, resume via `last-event-id` | `@orpc/standard-server` `index.mjs` (~268 LOC) | **NO (MVP).** Big chunk of oRPC, entirely skippable unless we add streaming endpoints. | +| **content-type negotiation** (JSON / form-urlencoded / multipart / event-stream / text / blob) | Picks a body parser by `content-type` | `standard-server-fetch` `toStandardBody` | **MINIMAL.** We only need `application/json` (+ maybe form-urlencoded). ~10 LOC. | +| **"detailed" input/output structures** | `{params, query, headers, body}` in, `{status, headers, body}` out | codec + spec-gen | **NO (MVP).** Default is "compact"; detailed is opt-in. Skip unless a handler needs custom status/headers. | +| **lazy / contract-first routers, hibernation, plugins, batch, peer, ws** | Code-splitting routers, durable-object hibernation, request batching, websockets | server core + plugins | **NO.** Not applicable to a simple function registry. | + +**Net:** the only "hard part" that is even arguably on our path is **bracket-notation nested +query/form** and **param coercion**, and both are deferrable / schema-side. Everything else big in +oRPC is RPC-protocol or edge-feature surface area we don't touch. + +--- + +## 4. Error handling → HTTP status + +`ORPCError` (`@orpc/client/dist/shared/client.D9eWXdBV.mjs:91`) carries `{defined, code, status, +message, data}`. Status is resolved from a fixed table: + +```js +// client.D9eWXdBV.mjs:6 (COMMON_ORPC_ERROR_DEFS) +BAD_REQUEST:400, UNAUTHORIZED:401, FORBIDDEN:403, NOT_FOUND:404, METHOD_NOT_SUPPORTED:405, +NOT_ACCEPTABLE:406, TIMEOUT:408, CONFLICT:409, PRECONDITION_FAILED:412, PAYLOAD_TOO_LARGE:413, +UNSUPPORTED_MEDIA_TYPE:415, UNPROCESSABLE_CONTENT:422, TOO_MANY_REQUESTS:429, +CLIENT_CLOSED_REQUEST:499, INTERNAL_SERVER_ERROR:500, NOT_IMPLEMENTED:501, BAD_GATEWAY:502, +SERVICE_UNAVAILABLE:503, GATEWAY_TIMEOUT:504 +``` + +```js +function fallbackORPCErrorStatus(code, status) { + return status ?? COMMON_ORPC_ERROR_DEFS[code]?.status ?? 500; +} +function isORPCErrorStatus(status) { return status < 200 || status >= 400; } // "this is an error response" +``` + +Error response body is `error.toJSON()`: +```js +toJSON() { return { defined: this.defined, code: this.code, status: this.status, + message: this.message, data: this.data }; } +``` + +Validation errors specifically: +- **Input** invalid → `ORPCError("BAD_REQUEST")` (400), `data.issues = [...standard-schema issues]`. +- **Output** invalid → `ORPCError("INTERNAL_SERVER_ERROR")` (500). +- **Malformed body / decode failure** → `BAD_REQUEST` (400). +- Unknown thrown value → `toORPCError` wraps to `INTERNAL_SERVER_ERROR` (500). + +This whole subsystem is ~80 trivial LOC to own. We'd define our own `FnError` (or reuse this +shape — it's a clean JSON contract orval-side already understands as just a JSON error body). + +--- + +## 5. OpenAPI spec generation — and its coupling to serving + +`OpenAPIGenerator.generate(router, opts)` (`openapi.BwdtJjDu.mjs:504`) walks the router/contract, +and for each procedure builds an OpenAPI 3.1.1 operation: +- `method` from `route.method` (default POST), `path` from `route.path` → `toOpenAPIPath`. +- **request**: converts `inputSchema` (Standard Schema) → JSON Schema via a pluggable + `CompositeSchemaConverter`, then splits it into **path params / query params / requestBody** based + on input structure + dynamic params + GET-vs-body. (`#request`, lines 647-740.) +- **responses**: success response from `outputSchema` (`#successResponse`), plus an error response + per status derived from the contract's `errorMap` (`#errorResponse`). +- finally `serializer.serialize(doc)` and returns the doc. + +The non-trivial weight here is the **JSON-Schema massaging** (lines 53-485): `LOGIC_KEYWORDS`, +`isAnySchema`/`isNeverSchema`, `separateObjectSchema` (pull path params out of the body schema), +`filterSchemaBranches` (peel `File` schemas into `multipart/form-data`), `simplifyComposedObjectJson +SchemasAndRefs` (flatten anyOf/oneOf/allOf of objects), `expandUnionSchema`, deepObject-vs-explode +decisions in `toOpenAPIParameters`. That's where the ~880 LOC goes. + +**BUT:** the generator needs a **schema converter** that turns *your* schema library into JSON +Schema. oRPC ships separate `@orpc/zod`, `@orpc/valibot`, `@orpc/arktype` converter packages for +that — the core generator is converter-agnostic (`CompositeSchemaConverter`). + +**Coupling verdict:** +- **Spec-gen and the handler are fully decoupled.** `OpenAPIGenerator` lives in `@orpc/openapi` + alongside the handler but shares only small helpers (`toOpenAPIPath`, `standardizeHTTPPath`, + `getDynamicParams`). You can run the generator without ever serving, and serve without the + generator (the handler matches on routes, not on the spec). +- For **us**: we need a spec for orval no matter what. We have two clean options: + 1. **Own a minimal spec-gen** that handles only our supported schema shapes (objects of scalars + + nested objects/arrays). With a single fixed schema lib (say zod via `zod-to-json-schema`, which + aftrak already has) this is far less code than oRPC's library-agnostic monster — because we skip + the `File`/multipart/event-stream/detailed-structure branches and the union-flattening for + params. + 2. **Reuse oRPC's `OpenAPIGenerator` + its zod converter** purely as a build-time dep, and still + own serving. Spec-gen is the part most worth *not* reinventing if we want broad schema support; + serving is the part most worth owning. + +--- + +## 6. Adapter layer — framework-agnostic ↔ platform Request/Response + +The boundary is the **"standard request/response"** shape: a plain object +`{ url:URL, method, headers, signal, body():Promise<...> }` in, `{ status, headers, body }` out. +Adapters are thin translators. The fetch adapter: + +```js +// @orpc/server/dist/adapters/fetch/index.mjs (FetchHandler.handle) +const standardRequest = toStandardLazyRequest(request); // Request -> standard +const result = await this.standardHandler.handle(standardRequest, options); +if (!result.matched) return result; +return { matched: true, response: toFetchResponse(result.response) }; // standard -> Response +``` + +```js +// @orpc/standard-server-fetch/dist/index.mjs:253 / :280 +function toStandardLazyRequest(request) { + return { url: new URL(request.url), signal: request.signal, method: request.method, + body: once(() => toStandardBody(request, { signal: request.signal })), + get headers() { ... toStandardHeaders(request.headers) ... } }; +} +function toFetchResponse(response) { + const headers = toFetchHeaders(response.headers); + const body = toFetchBody(response.body, headers); // JSON.stringify / FormData / stream / blob + return new Response(body, { headers, status: response.status }); +} +``` + +`toStandardBody` is the content-type switch (JSON/FormData/URLSearchParams/SSE/text/blob); +`toFetchBody` picks an outgoing encoding (string JSON / FormData / ReadableStream for SSE / Blob). +For **us**, this entire adapter collapses to: read `await request.json()` (or text + JSON.parse), +read `request.headers`, `new URL(request.url)`; and on the way out `new Response(JSON.stringify(body), +{status, headers:{'content-type':'application/json'}})`. The node adapter is the same idea over +`IncomingMessage`/`ServerResponse`. **~30-50 LOC each, trivial.** + +--- + +## 7. Size / effort estimate + +### LOC of each relevant oRPC piece (dist `.mjs`, v1.14.6) +| Piece | LOC | +|---|---| +| OpenAPI handler (matcher + codec + handler class) | ~205 | +| OpenAPI spec generator + schema massaging | ~880 | +| StandardHandler engine (incl. interceptors/spans) | ~226 | +| validate/middleware/procedure-client shared | ~418 | +| OpenAPI JSON serializer + serializer wrapper + link codec | ~302 | +| bracket-notation serializer | ~146 | +| `ORPCError` + status table + helpers | ~150 (we'd use ~80) | +| standard-server (SSE/headers/content-disposition) | ~268 | +| standard-server-fetch adapter | ~300 | +| fetch server adapter | ~167 | + +Most of those LOC are RPC-protocol, edge-features, OTel spans, interceptor plumbing, and +library-agnostic schema gymnastics that **we don't need**. + +### What WE'D actually build for the MVP (standard JSON OpenAPI) +A minimal owned handler that: matches route+method, parses JSON body + path + query params, +validates input via Standard Schema, calls our fn, validates output, maps errors→status, returns +JSON. + +| MVP component | Est. LOC | Notes | +|---|---|---| +| Route table + matcher | 40-100 | Use `rou3` (same dep oRPC uses) or a small regex matcher. `{id}`→`:id`. | +| Request decode (JSON body + query→object + merge path params) | 30 | `await req.json()`, `Object.fromEntries(url.searchParams)`, merge. | +| Standard-Schema input validation → 400 w/ issues | 20 | `schema['~standard'].validate(input)`. | +| Call handler | 5 | | +| Standard-Schema output validation → 500 | 15 | optional | +| Error → status mapping + JSON error body | 40 | copy the status table + `toJSON` shape. | +| Response build (`new Response(JSON.stringify(...))`) | 20 | | +| fetch + node adapters | 60-100 | two thin translators. | +| **MVP total** | **~250-400 LOC** | ~**0.5-1 day**. | + +### The long tail (add only when a real feature needs it) +| Feature | Est. effort | Trigger | +|---|---|---| +| String→typed coercion of path/query params | ~0.5 day (or free via `z.coerce`) | non-string scalar params | +| Bracket-notation nested query/form decode | ~1 day (port oRPC's ~146 LOC) | nested objects in query/form | +| Rich-type output normalization (Date/Set/Map/bigint) | ~0.5 day (port ~45 LOC) | handlers returning rich JS types | +| Multipart / file upload + download | 1-2 days | binary endpoints | +| SSE / streaming / event-iterators | 2-4 days | streaming endpoints (this is a real chunk) | +| Detailed input/output (custom status/headers per route) | ~1 day | per-route status/header control | +| **OpenAPI spec generation** | 2-4 days to own well, OR ~0 days reusing `@orpc/openapi`+`@orpc/zod` build-time | orval needs a spec regardless | + +--- + +## Recommendation: own the handler, decide spec-gen separately + +1. **Own the HTTP handler.** It is ~250-400 LOC for a fully standard JSON/OpenAPI experience and + removes the "we're just an oRPC wrapper" concern. oRPC's serving size is dominated by its **RPC + protocol** and **edge features we don't use**. Owning it also lets the `{name, description, + inputSchema, outputSchema, route, handler}` shape stay our own native concept instead of being + marshalled through oRPC contracts/procedures. + +2. **Do NOT adopt the RPC stack** (`RPCHandler`/`RPCLink`/super-JSON codec). It's irrelevant to + orval-consumes-OpenAPI and is the single biggest source of oRPC complexity. + +3. **Spec generation is the one place to think twice.** It's decoupled from serving, and orval needs + a spec from us either way. Two viable paths: + - *Lean & owned*: a focused generator over a single schema lib (e.g. zod → + `zod-to-json-schema`) covering objects/scalars/nested — much smaller than oRPC's + library-agnostic generator because we drop File/multipart/SSE/detailed/union-param branches. + - *Reuse at build time*: depend on `@orpc/openapi` + `@orpc/` only as a dev/build + dependency to emit the spec, while still owning runtime serving. This keeps us off oRPC at + runtime entirely (no oRPC in the server request path or the shipped client). + +4. **Borrow, don't depend, for the small gnarly bits.** The error/status table (~80 LOC), the + JSON-normalize serializer (~45 LOC), and (later) bracket-notation (~146 LOC) are clean, + self-contained, MIT-licensed files we can port verbatim when/if needed — no runtime dependency. + +5. **Keep `rou3`** as a tiny external dependency for routing (it's well-tested, ~a few KB, and oRPC + itself relies on it) unless you prefer a hand-rolled matcher. + +**Bottom line:** owning the standard-OpenAPI HTTP layer is a low-risk ~1-day MVP. The scary-looking +size of oRPC is mostly its RPC protocol and optional features. The only piece with real ongoing cost +is the spec generator, and that is independently swappable — you can own serving today and decide on +spec-gen (own vs. build-time-reuse) separately. diff --git a/packages/fn/notes/type-requirements.md b/packages/fn/notes/type-requirements.md new file mode 100644 index 0000000..e24e632 --- /dev/null +++ b/packages/fn/notes/type-requirements.md @@ -0,0 +1,5 @@ + +- user should be able to pass in input/output schema +- if input schema is defined then should use z.input to infer TInput, else use void +- if output schema is definde the should use z.output to infer TOutput, HOWEVER (IMPORANTLY) if no output schema is defined then should use the return type of the handler +- we dont want to waste time perfectly settng up all the middleware types are there is ONE main usecase that we need the middleware types to work for (for now). That usecase is in composition.test.ts. I'ts about creating these authed vs public functions. We dont mind casting the ctx type in initCreateFn. \ No newline at end of file diff --git a/packages/fn/package.json b/packages/fn/package.json new file mode 100644 index 0000000..74da8ab --- /dev/null +++ b/packages/fn/package.json @@ -0,0 +1,35 @@ +{ + "name": "@davstack/fn", + "version": "1.1.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "license": "MIT", + "files": [ + "dist/**" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --dts-resolve", + "dev": "tsup src/index.ts --format esm,cjs --watch --dts --dts-resolve", + "lint": "eslint \"src/**/*.ts*\"", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" + }, + "devDependencies": { + "@davstack/eslint-config": "workspace:*", + "@davstack/tsconfig": "workspace:*", + "@types/react": "^18.2.61", + "@types/react-dom": "^18.2.19", + "eslint": "^8.57.0", + "react": "^18.2.0", + "tsup": "^8.0.2", + "typescript": "^5.3.3", + "zod": ">=3.25.0" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } +} diff --git a/packages/fn/src/create-fn.ts b/packages/fn/src/create-fn.ts new file mode 100644 index 0000000..971a7af --- /dev/null +++ b/packages/fn/src/create-fn.ts @@ -0,0 +1,401 @@ +/* eslint-disable no-unused-vars */ + +import { FnError, isFnError } from './errors'; +import { isFormData, formDataToObject } from './utils/form-data'; +import { Simplify, zInfer, zInferInput, ZodTypeAny } from './utils/type-utils'; + +// Re-export FnError for convenience +export { FnError }; + +// #region --- Fn Types --- + +type InferInput = TInputSchema extends ZodTypeAny + ? zInferInput + : void; + +type MaybeZInfer = T extends ZodTypeAny ? zInfer : void; + +type InferOutput = TOutputSchema extends ZodTypeAny + ? zInfer + : THandler extends (...args: any[]) => Promise + ? R + : THandler extends (...args: any[]) => infer R + ? R + : any; + +type AnyObject = Record; + +/** + * The core handler function's signature, generic over schema types (not inferred types). + */ +export type FnHandler< + TInputSchema extends ZodTypeAny | undefined = undefined, + TOutputSchema extends ZodTypeAny | undefined = undefined, + TContext extends AnyObject | undefined = undefined, +> = ( + args: { + // we use infer input on the caller (eg safeCall, or directDirect call with Fn(), but this handler type is used for the DEFINITION so internally we can trust we'll have the defaults so can use infer output instead (will be parsed) + input: MaybeZInfer; + } & (TContext extends undefined ? { ctx?: any } : { ctx: TContext }) +) => TOutputSchema extends ZodTypeAny + ? zInfer | Promise> + : any | Promise; + +/** + * The definition object for creating a function. Uses schema types, not inferred types. + */ +export type FnDef< + TInputSchema extends ZodTypeAny | undefined = undefined, + TOutputSchema extends ZodTypeAny | undefined = undefined, + TContext extends AnyObject | undefined = undefined, +> = { + name: string; + description?: string; + tags?: string[]; + inputSchema?: TInputSchema; + outputSchema?: TOutputSchema; + handler: FnHandler; + middleware?: Middleware[]; +}; + +export type OptionallyRequiredField< + K extends string, + Condition, + T extends Record, +> = Condition extends void | undefined + ? Omit & Partial> + : unknown extends Condition + ? Omit & Partial> + : T; + +/** + * Helper type for function arguments with optional input and context. + */ +// export type FnArgs = { +// input: TInput; +// ctx: TContext; +// }; + +// export type FnArgs< +// TInput = void, +// TContext extends AnyObject | undefined = undefined, +// > = Simplify< +// OptionallyRequiredField< +// 'input', +// TInput, +// OptionallyRequiredField< +// 'ctx', +// TContext, +// { +// input: TInput; +// ctx: TContext; +// } +// > +// > +// >; + +// /** +export type FnArgs< + TInput = void, + TContext extends AnyObject | undefined = undefined, +> = Simplify< + // Maybe input + (TInput extends undefined | void ? { input?: void } : { input: TInput }) & + // Maybe context + (TContext extends undefined ? { ctx?: undefined } : { ctx: TContext }) +>; + +/** + * The main, user-facing Function type. Clean types only. + */ +export type Fn< + TInputSchema extends ZodTypeAny | undefined = undefined, + TOutputSchema extends ZodTypeAny | undefined = undefined, + TContext extends AnyObject | undefined = undefined, + THandler extends FnHandler = FnHandler< + TInputSchema, + TOutputSchema, + TContext + >, +> = FnDef & + // Maybe inputSchema + (TInputSchema extends undefined + ? { inputSchema?: undefined } + : { inputSchema: TInputSchema }) & + // Maybe outputSchema + (TOutputSchema extends undefined + ? { outputSchema?: undefined } + : { outputSchema: TOutputSchema }) & (( + args: FnArgs, TContext> + ) => Promise>); + +// #endregion + +// #region --- Middleware  --- + +// Internal type for middleware that needs to work with any function definition +type AnyFnDef = { + name: string; + description?: string; + tags?: string[]; + inputSchema?: ZodTypeAny; + outputSchema?: ZodTypeAny; + handler: (...args: any[]) => any; + middleware?: Middleware[]; +}; + +export type Middleware< + TContext extends AnyObject | undefined = undefined, + TNewContext extends AnyObject | undefined = TContext, +> = (opts: { + ctx: TContext; + input: unknown; + def: AnyFnDef; + next: (ctx?: TNewContext, input?: unknown) => Promise | unknown; +}) => Promise | unknown; + +/** + * Helper to create properly typed middleware. + */ +export function createMiddleware< + TContext extends AnyObject | undefined = undefined, + TNewContext extends AnyObject | undefined = TContext, +>( + middlewareFn: Middleware +): Middleware { + return middlewareFn; +} + +/** + * Executes a chain of middleware and the final handler. + */ +async function executeMiddleware(opts: { + def: AnyFnDef; + args: { input: unknown; ctx: any }; +}): Promise { + const { def, args } = opts; + + const run = async ( + index: number, + currentCtx: any, + currentInput: any + ): Promise => { + if (index >= (def.middleware?.length ?? 0)) { + // Pass the potentially modified context and the final input to the handler + return def.handler({ input: currentInput, ctx: currentCtx }) as T; + } + + const middleware = def.middleware?.[index]!; + + // The `next` function for this specific middleware. + // It will call `run` for the *next* middleware. + // If the current middleware provides a new context or input, we use it. + // Otherwise, we pass along the ones we received. + const next = (newCtx: any = currentCtx, newInput: any = currentInput) => { + return run(index + 1, newCtx, newInput); + }; + + return middleware({ + ctx: currentCtx, + input: currentInput, + def, + next, + }) as T; + }; + + return run(0, args.ctx, args.input); +} + +/** + * Enhances an error by wrapping it in an FnError (if it isn't one already), + * and adds a trace of function calls to the metadata. + * This ensures the original error `cause` and stack trace are preserved. + */ +function enhanceError(error: unknown, def: AnyFnDef, input: unknown): FnError { + const isOriginalFnError = error instanceof FnError; + // FnError.from will return the same instance if it's already an FnError + const fnError = FnError.from(error); + + // If this is the first time we're wrapping the error, initialize the trace and add input + if (!isOriginalFnError) { + fnError.meta.functionTrace = []; + fnError.meta.input = input; + } + + // Add the current function to the start of the trace to build a call stack + // e.g., [outerFn, middleFn, innerFn] + const trace = (fnError.meta.functionTrace || []) as string[]; + trace.unshift(def.name); + fnError.meta.functionTrace = trace; + + // The `functionName` should reflect the function that most recently handled the error + fnError.meta.functionName = def.name; + + return fnError; +} + +// #endregion + +// #region --- Default Middleware --- + +/** + * Input validation middleware. + */ +const withInputValidation = createMiddleware(({ ctx, input, def, next }) => { + const schema = def.inputSchema; + if (!schema) { + // If no schema, pass input through unchanged. + return next(ctx, input); + } + + const dataToValidate = isFormData(input) ? formDataToObject(input) : input; + + const result = (schema as any).safeParse(dataToValidate); + if (!result.success) { + throw new FnError({ + code: 'INVALID_INPUT', + cause: result.error, + meta: { zodErrors: result.error.flatten() }, + }); + } + // Pass the PARSED data to the next middleware/handler. + return next(ctx, result.data); +}); + +/** + * Output validation middleware. + */ +const withOutputValidation = createMiddleware( + async ({ ctx, input, def, next }) => { + const schema = def.outputSchema; + // The call to next() needs to pass the input along + if (!schema) return next(ctx, input); + + const result = await next(ctx, input); // pass input through + const validationResult = (schema as any).safeParse(result); + if (!validationResult.success) { + throw new FnError({ + code: 'INVALID_OUTPUT', + cause: validationResult.error, + meta: { zodErrors: validationResult.error.flatten() }, + }); + } + return validationResult.data; + } +); + +/** + * Error handling middleware that enhances thrown errors. + */ +const withThrowingErrorHandler = createMiddleware( + async ({ ctx, input, def, next }) => { + try { + return await next(ctx, input); // pass input through + } catch (error) { + throw enhanceError(error, def, input); + } + } +); + +// #endregion + +// #region --- createFn --- + +/** + * Creates a new function with middleware and validation capabilities. + * Schema generics are only here to do the inference, then we return clean types. + */ +export function createFn< + TInputSchema extends ZodTypeAny | undefined = undefined, + TOutputSchema extends ZodTypeAny | undefined = undefined, + TContext extends AnyObject | undefined = undefined, + // need it for inferring the return type of the handler + THandler extends FnHandler = FnHandler< + TInputSchema, + TOutputSchema, + TContext + >, +>( + def: FnDef & { + handler: THandler; + } +): Fn { + // Infer the clean types from schemas/handler + type TInput = InferInput; + type TOutput = InferOutput; + + const getArgs = (args: any): { input: any; ctx: any } => { + const _args = args ?? {}; + const input = 'input' in _args ? _args.input : undefined; + const ctx = 'ctx' in _args ? _args.ctx : {}; + return { input, ctx }; + }; + + // The pipeline for the direct, throwing call. + const directCall = async (args: any) => { + const callMiddleware: Middleware[] = [ + withThrowingErrorHandler, + // need to validate input so that we get eg defaults/transforms. + // for fully raw call can use .handler() directly + withInputValidation, + ...(def.middleware || []), + withOutputValidation, + ]; + + return executeMiddleware({ + def: { ...def, middleware: callMiddleware } as AnyFnDef, + args: getArgs(args), + }); + }; + + const { name, ...defWithoutName } = def; + + const result = Object.assign(directCall, defWithoutName); + + Object.defineProperty(result, 'name', { + value: def.name, + configurable: true, + }); + + return result as unknown as Fn< + TInputSchema, + TOutputSchema, + TContext, + THandler + >; +} + +// #endregion + +// #region --- initCreateFn --- + +/** + * Primary initialization function - creates a function factory with pre-configured middleware. + * Only stores the context type, schema inference happens in the returned createFn calls. + */ +export function initCreateFn< + TContext extends AnyObject | undefined = undefined, +>(middlewares?: Middleware[]) { + return < + TInputSchema extends ZodTypeAny | undefined = undefined, + TOutputSchema extends ZodTypeAny | undefined = undefined, + THandler extends FnHandler< + TInputSchema, + TOutputSchema, + TContext + > = FnHandler, + >( + def: FnDef & { + handler: THandler; + } + ): Fn => + createFn({ + ...def, + middleware: [ + ...(middlewares || []), + ...(def.middleware || []), + ] as Middleware[], + }); +} + +// #endregion diff --git a/packages/fn/src/errors.ts b/packages/fn/src/errors.ts new file mode 100644 index 0000000..60c22ad --- /dev/null +++ b/packages/fn/src/errors.ts @@ -0,0 +1,153 @@ +/** + * Standardized error codes for Fn operations + */ +export const FnErrorCode = { + BAD_REQUEST: 'BAD_REQUEST', + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + NOT_FOUND: 'NOT_FOUND', + METHOD_NOT_SUPPORTED: 'METHOD_NOT_SUPPORTED', + TIMEOUT: 'TIMEOUT', + CONFLICT: 'CONFLICT', + PRECONDITION_FAILED: 'PRECONDITION_FAILED', + PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE', + UNPROCESSABLE_CONTENT: 'UNPROCESSABLE_CONTENT', + TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS', + CLIENT_CLOSED_REQUEST: 'CLIENT_CLOSED_REQUEST', + INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', + NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', + BAD_GATEWAY: 'BAD_GATEWAY', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + GATEWAY_TIMEOUT: 'GATEWAY_TIMEOUT', + INVALID_INPUT: 'INVALID_INPUT', + INVALID_OUTPUT: 'INVALID_OUTPUT', +} as const; + +export type FnErrorCodeType = keyof typeof FnErrorCode; + +/** + * Options for creating a new FnError + */ +export interface FnErrorOptions { + code: FnErrorCodeType; + message?: string; + cause?: unknown; // Allow any cause + meta?: Record; +} + +/** + * Checks if an object is a plain object (not null, not array) + */ +function isObject(obj: unknown): obj is Record { + return typeof obj === 'object' && obj !== null && !Array.isArray(obj); +} + +/** + * Gets a message from an unknown error + */ +function getMessageFromUnknownError(err: unknown, fallback: string): string { + if (typeof err === 'string') { + return err; + } + if (isObject(err) && typeof err['message'] === 'string') { + return err['message']; + } + return fallback; +} + +/** + * Custom error class for Fn operations + */ +export class FnError extends Error { + code: FnErrorCodeType; + cause?: unknown; + meta: Record; + _reported?: boolean; + + constructor(opts: FnErrorOptions) { + // Use custom message or default based on the error code + const message = opts.message || getDefaultErrorMessage(opts.code); + super(message, { cause: opts.cause }); + + this.name = 'FnError'; + this.code = opts.code; + this.cause = opts.cause; + this.meta = opts.meta || {}; + + // Set prototype explicitly since TypeScript classes lose their prototype chain when extended + Object.setPrototypeOf(this, FnError.prototype); + } + + /** + * Mark the error as reported to prevent duplicate logging. + * This is a mutable operation. + */ + markAsReported(): this { + this._reported = true; + return this; + } + + /** + * Creates an FnError from any error or object. + * If the cause is already an FnError, it enhances it rather than re-wrapping, + * preserving the original error identity and stack trace. + */ + static from( + cause: unknown, + opts: { + code?: FnErrorCodeType; + message?: string; + meta?: Record; + } = {} + ): FnError { + // If it's already an FnError, just enhance it without creating a new one + if (isFnError(cause)) { + if (opts.meta) { + // Merge the new meta data with existing data + cause.meta = { ...cause.meta, ...opts.meta }; + } + // Apply other options if provided + if (opts.code) cause.code = opts.code; + if (opts.message) cause.message = opts.message; + + return cause; + } + + // Create a new FnError that references the original cause + return new FnError({ + code: opts.code || 'INTERNAL_SERVER_ERROR', + message: + opts.message || getMessageFromUnknownError(cause, 'Unknown error'), + cause: cause, + meta: opts.meta || {}, + }); + } +} + +/** + * Gets a default error message for a given error code + */ +function getDefaultErrorMessage(code: FnErrorCodeType): string { + switch (code) { + case 'BAD_REQUEST': + return 'Invalid request'; + case 'UNAUTHORIZED': + return 'Not authenticated'; + case 'FORBIDDEN': + return 'Not authorized'; + case 'NOT_FOUND': + return 'Resource not found'; + case 'INTERNAL_SERVER_ERROR': + return 'An unexpected error occurred'; + // ... other cases\ + default: + return `Error: ${code}`; + } +} + +/** + * Type guard to check if an error is an FnError + */ +export function isFnError(error: unknown): error is FnError { + return error instanceof FnError; +} diff --git a/packages/fn/src/index.ts b/packages/fn/src/index.ts new file mode 100644 index 0000000..95830a3 --- /dev/null +++ b/packages/fn/src/index.ts @@ -0,0 +1,7 @@ +export * from './create-fn'; +export * from './errors'; +export * from './utils/type-utils'; +// export const helloWorld = () => { +// console.log('Hello, world!'); +// return 'Hello, world!'; +// }; diff --git a/packages/fn/src/utils/form-data.ts b/packages/fn/src/utils/form-data.ts new file mode 100644 index 0000000..04d1516 --- /dev/null +++ b/packages/fn/src/utils/form-data.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +/** + * CREDIT: https://github.com/trpc/trpc/blob/72c684683626b54907cccf8a12f3a3d726471652/packages/next/src/app-dir/formDataToObject.ts + */ + +export function isFormData(value: unknown): value is FormData { + if (typeof FormData === 'undefined') { + // FormData is not supported + return false; + } + return value instanceof FormData; +} + +function set( + obj: Record, + path: string[] | string, + value: unknown +): void { + if (typeof path === 'string') { + path = path.split(/[\.\[\]]/).filter(Boolean); + } + + if (path.length > 1) { + const p = path.shift()!; + const isArrayIndex = /^\d+$/.test(path[0]!); + obj[p] = obj[p] || (isArrayIndex ? [] : {}); + set(obj[p], path, value); + return; + } + const p = path[0]!; + if (obj[p] === undefined) { + obj[p] = value; + } else if (Array.isArray(obj[p])) { + obj[p].push(value); + } else { + obj[p] = [obj[p], value]; + } +} + +export function formDataToObject(formData: FormData) { + const obj: Record = {}; + + for (const [key, value] of formData.entries()) { + set(obj, key, value); + } + + return obj; +} + +export function getMaybeFormDataValue(value: T | FormData): T { + if (isFormData(value)) { + return formDataToObject(value) as any; + } + + return value; +} diff --git a/packages/fn/src/utils/type-utils.ts b/packages/fn/src/utils/type-utils.ts new file mode 100644 index 0000000..31f9391 --- /dev/null +++ b/packages/fn/src/utils/type-utils.ts @@ -0,0 +1,41 @@ +// CHANGED: Import from the stable v3 and v4 core subpaths +import * as z3 from 'zod/v3'; +// import * as z4 from 'zod/v4/core'; + +/** + * @link https://github.com/ianstormtaylor/superstruct/blob/7973400cd04d8ad92bbdc2b6f35acbfb3c934079/src/utils.ts#L323-L325 + */ +export type Simplify = T extends any[] | Date + ? T + : { [K in keyof T]: T[K] } & {}; + +//* MARK: OLD ------------ +// export type zInfer = T['_output']; +// export type zInferInput = T['_input']; + +// export type ZodTypeAny = z3.ZodTypeAny; + +//* MARK: NEW ------------ + +// NEW: Version-agnostic type inference for the schema's output type. +// It uses conditional types to check if the provided schema is a v3 or v4 type. + +import * as z4 from 'zod/v4/core'; +export type zInfer = T extends z3.ZodTypeAny + ? z3.infer + : T extends z4.$ZodType + ? z4.output + : never; + +// NEW: Version-agnostic type inference for the schema's input type. +export type zInferInput = T extends z3.ZodTypeAny + ? z3.input + : T extends z4.$ZodType + ? z4.input + : never; + +export type ZodTypeAny = z3.ZodTypeAny | z4.$ZodType; + +export function isZod4(schema: ZodTypeAny): schema is z4.$ZodType { + return '_zod' in schema; +} diff --git a/packages/fn/test/composition.test.ts b/packages/fn/test/composition.test.ts new file mode 100644 index 0000000..928e5af --- /dev/null +++ b/packages/fn/test/composition.test.ts @@ -0,0 +1,621 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { z } from 'zod'; +import { initCreateFn, FnError } from '../src'; +import { + AuthedServerFnCtx, + createMockLogger, + mockDb, + ServerFnCtx, + loggingMiddleware, + authMiddleware, + authedLoggingMiddleware, + authedErrorLoggingMiddleware, + createTimingMiddleware, +} from './test-utils'; +import { expectTypeOf } from 'vitest'; + +describe('Clean Composition API', () => { + const logger = createMockLogger(); + beforeEach(() => logger.cleanup()); + + describe('Middleware Creation', () => { + test('should create and use right runtime types', () => { + expect(typeof loggingMiddleware).toBe('function'); + expect(typeof authMiddleware).toBe('function'); + const timingMiddleware = createTimingMiddleware(); + expect(typeof timingMiddleware).toBe('function'); + }); + }); + + describe('Function Factory Creation', () => { + test('should create clean public server function factory', async () => { + const createServerFn = initCreateFn([loggingMiddleware]); + + expect(typeof createServerFn).toBe('function'); + + const getPublicData = createServerFn({ + name: 'getPublicData', + handler: async ({ ctx }) => { + return `Public data for ${ctx.user?.id || 'anonymous'}`; + }, + }); + + expect(typeof getPublicData).toBe('function'); + expect(getPublicData.name).toBe('getPublicData'); + + const result = await getPublicData({ + ctx: { logger, db: mockDb, user: { id: 'user_123' } }, + }); + + expect(typeof result).toBe('string'); + expect(result).toBe('Public data for user_123'); + expect(logger.info).toHaveBeenCalledWith("-> Calling 'getPublicData'"); + expect(logger.info).toHaveBeenCalledWith("<- Finished 'getPublicData'"); + }); + + test('should create clean authed server function factory', async () => { + const createAuthedServerFn = initCreateFn([ + authMiddleware, + authedLoggingMiddleware, + ]); + + expect(typeof createAuthedServerFn).toBe('function'); + + const getSecretData = createAuthedServerFn({ + name: 'getSecretData', + handler: async ({ ctx }) => { + return `Secret data for ${ctx.user.id}`; + }, + }); + + expect(typeof getSecretData).toBe('function'); + expect(getSecretData.name).toBe('getSecretData'); + + // Should succeed with proper user + const result = await getSecretData({ + ctx: { logger, db: mockDb, user: { id: 'user_123' } }, + }); + + expect(typeof result).toBe('string'); + expect(result).toBe('Secret data for user_123'); + expect(logger.info).toHaveBeenCalledWith("-> Calling 'getSecretData'"); + expect(logger.info).toHaveBeenCalledWith("<- Finished 'getSecretData'"); + }); + + test('should throw auth error for unauthenticated requests', async () => { + const createAuthedServerFn = initCreateFn([ + authMiddleware, + ]); + + const protectedFn = createAuthedServerFn({ + name: 'protected', + handler: async () => 'secret', + }); + + // Test with missing user + await expect( + protectedFn({ ctx: { logger, db: mockDb } } as any) + ).rejects.toThrow(FnError); + + // Test with undefined user + await expect( + protectedFn({ ctx: { logger, db: mockDb, user: undefined } } as any) + ).rejects.toThrow('User is not authenticated'); + }); + }); + + describe('Complex Nested Function Calls', () => { + test('should handle nested function calls with proper context passing', async () => { + const createAuthedServerFn = initCreateFn([ + authMiddleware, + authedErrorLoggingMiddleware, + ]); + + // Create individual functions + const checkCredits = createAuthedServerFn({ + name: 'checkCredits', + inputSchema: z.object({ cost: z.number() }), + handler: async ({ input, ctx }) => { + const credits = await ctx.db.credits.findFirst(); + return credits!.amount > input.cost; + }, + }); + + const sendSms = createAuthedServerFn({ + name: 'sendSms', + handler: async () => { + return { success: true }; + }, + }); + + const sendWelcomeText = createAuthedServerFn({ + name: 'sendWelcomeText', + handler: async ({ ctx }) => { + const hasEnoughCredits = await checkCredits({ + ctx, + input: { cost: 5 }, + }); + + if (!hasEnoughCredits) { + throw new FnError({ + code: 'FORBIDDEN', + message: 'Insufficient credits', + }); + } + + return await sendSms({ ctx }); + }, + }); + + // Test successful flow + const user = { id: 'user_with_credits' }; + const result = await sendWelcomeText({ + ctx: { logger, db: mockDb, user }, + }); + + expect(typeof result).toBe('object'); + expect(result.success).toBe(true); + + // Verify the logger was called for all functions in the chain + expect(logger.info).toHaveBeenCalledWith("-> Calling 'sendWelcomeText'"); + expect(logger.info).toHaveBeenCalledWith("-> Calling 'checkCredits'"); + expect(logger.info).toHaveBeenCalledWith("-> Calling 'sendSms'"); + }); + + test('should handle errors in nested calls', async () => { + const createAuthedServerFn = initCreateFn([ + authMiddleware, + authedErrorLoggingMiddleware, + ]); + + const failingFn = createAuthedServerFn({ + name: 'failing', + handler: async () => { + throw new Error('Something went wrong'); + }, + }); + + const callerFn = createAuthedServerFn({ + name: 'caller', + handler: async ({ ctx }) => { + return await failingFn({ ctx }); + }, + }); + + await expect( + callerFn({ ctx: { logger, db: mockDb, user: { id: 'test' } } }) + ).rejects.toThrow('Something went wrong'); + + expect(logger.error).toHaveBeenCalledWith( + expect.any(Error), + "Error in 'failing'" + ); + }); + }); + + describe('Array-style middleware composition', () => { + test('should support array-style middleware initialization', async () => { + const createServerFn = initCreateFn([ + loggingMiddleware, + createTimingMiddleware(), + ]); + + expect(typeof createServerFn).toBe('function'); + + const testFn = createServerFn({ + name: 'test', + handler: async () => 'result', + }); + + const result = await testFn({ ctx: { logger, db: mockDb } }); + + expect(typeof result).toBe('string'); + expect(result).toBe('result'); + expect(logger.info).toHaveBeenCalledWith("-> Calling 'test'"); + expect(logger.info).toHaveBeenCalledWith("<- Finished 'test'"); + expect(logger.info).toHaveBeenCalledWith( + expect.stringMatching(/'test' took \d+ms/) + ); + }); + + test('should handle empty middleware array', async () => { + const createServerFn = initCreateFn([]); + + expect(typeof createServerFn).toBe('function'); + + const simpleFn = createServerFn({ + name: 'simple', + handler: async () => ({ message: 'no middleware' }), + }); + + const result = await simpleFn({ ctx: { logger, db: mockDb } }); + + expect(typeof result).toBe('object'); + expect(result.message).toBe('no middleware'); + }); + + test('should handle middleware without explicit array parameter', async () => { + const createServerFn = initCreateFn(); + + expect(typeof createServerFn).toBe('function'); + + const defaultFn = createServerFn({ + name: 'default', + handler: async () => 'default behavior', + }); + + const result = await defaultFn({ ctx: { logger, db: mockDb } }); + + expect(typeof result).toBe('string'); + expect(result).toBe('default behavior'); + }); + }); + + describe('Direct call result behavior', () => { + test('direct call returns the value on success', async () => { + const createServerFn = initCreateFn([]); + + const testFn = createServerFn({ + name: 'safeTest', + inputSchema: z.object({ value: z.string() }), + handler: async ({ input }) => ({ + processed: input.value.toUpperCase(), + }), + }); + + const data = await testFn({ + input: { value: 'hello' }, + ctx: { logger, db: mockDb }, + }); + + expect(typeof data).toBe('object'); + expect(data).toEqual({ processed: 'HELLO' }); + }); + + test('direct call throws FnError on validation error', async () => { + const createServerFn = initCreateFn([]); + + const testFn = createServerFn({ + name: 'validationTest', + inputSchema: z.object({ value: z.string() }), + handler: async ({ input }) => input.value, + }); + + let error: any; + try { + await testFn({ + input: { value: 123 } as any, // Invalid input + ctx: { logger, db: mockDb }, + }); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(FnError); + }); + }); + + describe('Input and Context Type Inference', () => { + test('should properly infer simple input types inside handler', () => { + const createServerFn = initCreateFn([]); + + const simpleInputFn = createServerFn({ + name: 'simpleInput', + inputSchema: z.object({ + title: z.string(), + count: z.number(), + }), + handler: async ({ input, ctx }) => { + // Type assertions for input + expectTypeOf(input).toEqualTypeOf<{ + title: string; + count: number; + }>(); + expectTypeOf(input.title).toEqualTypeOf(); + expectTypeOf(input.count).toEqualTypeOf(); + + // Type assertions for context + expectTypeOf(ctx).toEqualTypeOf(); + + return `${input.title}: ${input.count}`; + }, + }); + + // Verify function call signature + expectTypeOf(simpleInputFn).parameter(0).toEqualTypeOf<{ + input: { title: string; count: number }; + ctx: ServerFnCtx; + }>(); + expectTypeOf(simpleInputFn).returns.resolves.toEqualTypeOf(); + }); + + test('should properly infer complex nested input types', () => { + const createServerFn = initCreateFn([]); + + const complexInputFn = createServerFn({ + name: 'complexInput', + inputSchema: z.object({ + user: z.object({ + id: z.string(), + profile: z.object({ + name: z.string(), + age: z.number().optional(), + }), + }), + metadata: z.record(z.any(), z.any()).optional(), + tags: z.array(z.string()), + }), + handler: async ({ input, ctx }) => { + // Type assertions for nested input structure + expectTypeOf(input).toEqualTypeOf<{ + user: { + id: string; + profile: { + name: string; + age?: number; + }; + }; + metadata?: Record; + tags: string[]; + }>(); + + expectTypeOf(input.user.id).toEqualTypeOf(); + expectTypeOf(input.user.profile.name).toEqualTypeOf(); + expectTypeOf(input.user.profile.age).toEqualTypeOf< + number | undefined + >(); + expectTypeOf(input.metadata).toEqualTypeOf< + Record | undefined + >(); + expectTypeOf(input.tags).toEqualTypeOf(); + + // Context should remain unchanged + expectTypeOf(ctx).toEqualTypeOf(); + + return { success: true, userId: input.user.id }; + }, + }); + + expectTypeOf(complexInputFn).parameter(0).toMatchTypeOf<{ + input: { + user: { + id: string; + profile: { + name: string; + age?: number; + }; + }; + metadata?: Record; + tags: string[]; + }; + ctx: ServerFnCtx; + }>(); + }); + + test('should handle no input schema correctly', () => { + const createServerFn = initCreateFn([]); + + const noInputFn = createServerFn({ + name: 'noInput', + handler: async ({ input, ctx }) => { + // When no input schema is provided, input should be void + expectTypeOf(input).toEqualTypeOf(); + expectTypeOf(ctx).toEqualTypeOf(); + + return 'no input needed'; + }, + }); + + expectTypeOf(noInputFn).parameter(0).toEqualTypeOf<{ + input?: void; + ctx: ServerFnCtx; + }>(); + }); + + test('should properly infer authed context types in handler', () => { + const createAuthedServerFn = initCreateFn([ + authMiddleware, + ]); + + const authedFn = createAuthedServerFn({ + name: 'authedFunction', + inputSchema: z.object({ + action: z.enum(['create', 'update', 'delete']), + }), + handler: async ({ input, ctx }) => { + // Input type should be properly inferred + expectTypeOf(input).toEqualTypeOf<{ + action: 'create' | 'update' | 'delete'; + }>(); + expectTypeOf(input.action).toEqualTypeOf< + 'create' | 'update' | 'delete' + >(); + + // Context should be the authed version with required user + expectTypeOf(ctx).toEqualTypeOf(); + expectTypeOf(ctx.user).toEqualTypeOf<{ id: string }>(); + expectTypeOf(ctx.user.id).toEqualTypeOf(); + + return { action: input.action, userId: ctx.user.id }; + }, + }); + + expectTypeOf(authedFn).parameter(0).toEqualTypeOf<{ + input: { action: 'create' | 'update' | 'delete' }; + ctx: AuthedServerFnCtx; + }>(); + }); + + test('should properly infer union and literal types', () => { + const createServerFn = initCreateFn([]); + + const unionTypeFn = createServerFn({ + name: 'unionTypes', + inputSchema: z.object({ + status: z.union([ + z.literal('active'), + z.literal('inactive'), + z.literal('pending'), + ]), + priority: z.enum(['low', 'medium', 'high']), + value: z.union([z.string(), z.number()]), + }), + handler: async ({ input, ctx }) => { + expectTypeOf(input).toEqualTypeOf<{ + status: 'active' | 'inactive' | 'pending'; + priority: 'low' | 'medium' | 'high'; + value: string | number; + }>(); + + expectTypeOf(input.status).toEqualTypeOf< + 'active' | 'inactive' | 'pending' + >(); + expectTypeOf(input.priority).toEqualTypeOf< + 'low' | 'medium' | 'high' + >(); + expectTypeOf(input.value).toEqualTypeOf(); + + return { + processedStatus: input.status, + processedPriority: input.priority, + processedValue: input.value, + }; + }, + }); + + expectTypeOf(unionTypeFn).returns.resolves.toEqualTypeOf<{ + processedStatus: 'active' | 'inactive' | 'pending'; + processedPriority: 'low' | 'medium' | 'high'; + processedValue: string | number; + }>(); + }); + + test('should properly infer types through middleware chain', () => { + const createAuthedServerFn = initCreateFn([ + authMiddleware, + authedLoggingMiddleware, + ]); + + const middlewareChainFn = createAuthedServerFn({ + name: 'middlewareChain', + inputSchema: z.object({ + data: z.object({ + items: z.array( + z.object({ + id: z.string(), + value: z.number(), + }) + ), + }), + }), + handler: async ({ input, ctx }) => { + // Even with middleware chain, types should be preserved + expectTypeOf(input).toEqualTypeOf<{ + data: { + items: Array<{ + id: string; + value: number; + }>; + }; + }>(); + + expectTypeOf(input.data.items).toEqualTypeOf< + Array<{ + id: string; + value: number; + }> + >(); + + // Context should still be the correct authed type + expectTypeOf(ctx).toEqualTypeOf(); + expectTypeOf(ctx.user.id).toEqualTypeOf(); + + return input.data.items.map((item) => ({ + ...item, + processed: true, + })); + }, + }); + + expectTypeOf(middlewareChainFn).returns.resolves.toEqualTypeOf< + Array<{ + id: string; + value: number; + processed: boolean; + }> + >(); + }); + + test('should handle optional input fields correctly', () => { + const createServerFn = initCreateFn([]); + + const optionalFieldsFn = createServerFn({ + name: 'optionalFields', + inputSchema: z.object({ + required: z.string(), + optional: z.string().optional(), + withDefault: z.string().default('default-value'), + nullable: z.string().nullable(), + }), + handler: async ({ input, ctx }) => { + expectTypeOf(input).toEqualTypeOf<{ + required: string; + optional?: string | undefined; + withDefault: string; + nullable: string | null; + }>(); + + expectTypeOf(input.required).toEqualTypeOf(); + expectTypeOf(input.optional).toEqualTypeOf(); + expectTypeOf(input.withDefault).toEqualTypeOf(); + expectTypeOf(input.nullable).toEqualTypeOf(); + + return { + required: input.required, + hasOptional: input.optional !== undefined, + withDefault: input.withDefault, + isNullable: input.nullable === null, + }; + }, + }); + + expectTypeOf(optionalFieldsFn).parameter(0).toMatchTypeOf<{ + input: { + required: string; + optional?: string; + withDefault?: string; // Zod default makes this optional in input + nullable: string | null; + }; + ctx: ServerFnCtx; + }>(); + }); + + test('should properly type direct call results', async () => { + const createServerFn = initCreateFn([]); + + const typedFn = createServerFn({ + name: 'typedDirectCall', + inputSchema: z.object({ value: z.number() }), + handler: async ({ input }) => ({ + doubled: input.value * 2, + original: input.value, + }), + }); + + // The direct call resolves to the inferred output type (it throws on failure). + expectTypeOf< + Awaited> + >().toEqualTypeOf<{ doubled: number; original: number }>(); + + const data = await typedFn({ + input: { value: 42 }, + ctx: { logger: createMockLogger(), db: mockDb }, + }); + + expectTypeOf(data).toEqualTypeOf<{ + doubled: number; + original: number; + }>(); + }); + }); +}); diff --git a/packages/fn/test/core.test.ts b/packages/fn/test/core.test.ts new file mode 100644 index 0000000..a323499 --- /dev/null +++ b/packages/fn/test/core.test.ts @@ -0,0 +1,235 @@ +import { describe, test, expect } from 'vitest'; +import { createFn, FnError } from '../src'; +import { z } from 'zod'; + +// Test function definition used across different suites +const testFn = createFn({ + name: 'testFn', + inputSchema: z.object({ title: z.string() }), + outputSchema: z.object({ id: z.string(), title: z.string() }), + handler: async ({ input }) => { + if (input.title === 'throw') { + throw new Error('Raw handler error'); + } + // Return invalid output shape to test output validation + if (input.title === 'invalid-output') { + return { id: 123, title: 'invalid' } as any; + } + return { id: 'chat_123', title: input.title }; + }, +}); + +//* MARK: Core +describe('Core `createFn` API', () => { + test('should create a function with definition properties attached', () => { + expect(typeof testFn).toBe('function'); + expect(testFn.name).toBe('testFn'); + expect(testFn.inputSchema).toBeDefined(); + expect(testFn.outputSchema).toBeDefined(); + expect(testFn.handler).toBeDefined(); + }); + + // MARK: Direct Call -> myFn({}) + describe('Direct Call (`myFn({})`)', () => { + test('should return data directly on success', async () => { + const result = await testFn({ input: { title: 'Success' } }); + expect(result).toEqual({ id: 'chat_123', title: 'Success' }); + }); + + test('should THROW an INVALID_INPUT error for invalid input', async () => { + const promise = testFn({ input: { title: 123 } as any }); + await expect(promise).rejects.toThrow(FnError); + await expect(promise).rejects.toHaveProperty('code', 'INVALID_INPUT'); + }); + + test('should THROW an INVALID_OUTPUT error for invalid output', async () => { + const promise = testFn({ input: { title: 'invalid-output' } }); + await expect(promise).rejects.toThrow(FnError); + await expect(promise).rejects.toHaveProperty('code', 'INVALID_OUTPUT'); + }); + + test('should THROW an enhanced FnError for handler errors', async () => { + const promise = testFn({ input: { title: 'throw' } }); + await expect(promise).rejects.toThrow(FnError); + await expect(promise).rejects.toHaveProperty( + 'code', + 'INTERNAL_SERVER_ERROR' + ); + await expect(promise).rejects.toHaveProperty( + 'message', + 'Raw handler error' + ); + }); + }); + + // MARK: Direct Call thrown-error coverage + describe('direct call thrown errors', () => { + test('should return data on success', async () => { + const data = await testFn({ + input: { title: 'Test' }, + }); + expect(data).toEqual({ id: 'chat_123', title: 'Test' }); + }); + + test('should THROW an INVALID_INPUT error for invalid input', async () => { + let error: any; + try { + await testFn({ input: { title: 123 } as any }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(FnError); + expect((error as FnError).code).toBe('INVALID_INPUT'); + }); + + test('should THROW an INVALID_OUTPUT error for invalid output', async () => { + let error: any; + try { + await testFn({ input: { title: 'invalid-output' } }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(FnError); + expect((error as FnError).code).toBe('INVALID_OUTPUT'); + }); + + test('should THROW an INTERNAL_SERVER_ERROR for handler errors', async () => { + let error: any; + try { + await testFn({ input: { title: 'throw' } }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(FnError); + expect((error as FnError).code).toBe('INTERNAL_SERVER_ERROR'); + }); + }); + + // MARK: Handler Call -> myFn.handler({}) + describe('.handler()', () => { + test('should return data on success without validation', async () => { + const result = await testFn.handler({ + input: { title: 'Success' }, + }); + expect(result).toEqual({ id: 'chat_123', title: 'Success' }); + }); + + test('should NOT validate input', async () => { + // show throw with direct call, due to input validation + await expect( + testFn({ + input: { title: null } as any, + }) + ).rejects.toThrow(FnError); + + // should NOT throw with handler call, as it bypasses input validation + await expect( + testFn.handler({ + input: { title: null } as any, + }) + ).resolves.not.toThrow(FnError); + }); + + test('should NOT validate output', async () => { + // This returns a shape that would fail output validation. + const result = await testFn.handler({ + input: { title: 'invalid-output' }, + }); + expect(result).toEqual({ id: 123, title: 'invalid' }); + }); + + test('should throw a RAW (non-FnError) error on failure', async () => { + const promise = testFn.handler({ + input: { title: 'throw' }, + }); + // It should reject with the original Error, not an FnError. + await expect(promise).rejects.toThrow('Raw handler error'); + await expect(promise).rejects.not.toBeInstanceOf(FnError); + }); + }); +}); + +//* MARK: FormData +describe('FormData Handling', () => { + const processForm = createFn({ + name: 'processForm', + inputSchema: z.object({ + name: z.string(), + age: z.coerce.number(), // Use coerce for FormData strings + isAdmin: z.coerce.boolean().default(false), + }), + handler: async ({ input }) => { + return `Name: ${input.name}, Age: ${input.age}, Admin: ${input.isAdmin}`; + }, + }); + + test('should handle FormData input with defaults in direct call', async () => { + const formData = new FormData(); + formData.append('name', 'John Doe'); + formData.append('age', '30'); + + const data = await processForm({ + input: formData as any, + }); + + expect(data).toBe('Name: John Doe, Age: 30, Admin: false'); + }); + + test('should handle FormData input in direct call', async () => { + const formData = new FormData(); + formData.append('name', 'Jane Doe'); + formData.append('age', '25'); + formData.append('isAdmin', 'true'); + + const result = await processForm({ input: formData as any }); + + expect(result).toBe('Name: Jane Doe, Age: 25, Admin: true'); + }); + + test('should throw INVALID_INPUT for malformed FormData in direct call', async () => { + const formData = new FormData(); + formData.append('name', 'Missing Age'); + // 'age' is missing, which is required by the schema. + + let error: any; + try { + await processForm({ input: formData as any }); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(FnError); + if (error instanceof FnError) { + expect(error.code).toBe('INVALID_INPUT'); + } + }); + + test('should handle complex nested objects from FormData', async () => { + const processNestedForm = createFn({ + name: 'processNestedForm', + inputSchema: z.object({ + user: z.object({ + name: z.string(), + }), + items: z.array(z.object({ id: z.coerce.number() })), + }), + handler: async ({ input }) => { + return `User: ${input.user.name}, Items: ${input.items + .map((i) => i.id) + .join(',')}`; + }, + }); + + const formData = new FormData(); + formData.append('user.name', 'Nested User5'); + formData.append('items[0].id', '101'); + formData.append('items[1].id', '102'); + + const data = await processNestedForm({ + input: formData as any, + }); + + console.dir(data, { depth: null }); + expect(data).not.toBe('User: Nested User, Items: 101,102'); + }); +}); diff --git a/packages/fn/test/error-reporting.test.ts b/packages/fn/test/error-reporting.test.ts new file mode 100644 index 0000000..fb79d48 --- /dev/null +++ b/packages/fn/test/error-reporting.test.ts @@ -0,0 +1,192 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { + createFn, + FnError, + createMiddleware, + initCreateFn, + isFnError, +} from '../src'; + +// --- Test Setup: A Simple Logger and a Focused Middleware --- + +/** + * A helper to create an error with a distinct, deep stack trace for testing. + */ +const createOriginalError = (message: string): Error => { + try { + // Use named functions so they are clearly identifiable in the stack trace. + function createErrorFrame1() { + function createErrorFrame2() { + throw new Error(message); + } + createErrorFrame2(); + } + createErrorFrame1(); + } catch (e) { + return e as Error; + } + throw new Error('Fallback error'); // Should not be reached +}; + +/** + * A simple logger that adheres to the principle of not being "too smart". + * It has a single `error` method that we can spy on. + */ +const createSimpleLogger = () => ({ + error: vi.fn((_error: Error, _context?: Record) => { + // In a real implementation, this would send the error and context + // to a service like Sentry, Pino, etc. + }), + cleanup: () => { + vi.clearAllMocks(); + }, +}); + +type TestContext = { + logger: ReturnType; +}; + +/** + * This middleware embodies the new, simpler philosophy. It's designed to: + * 1. Catch any error. + * 2. If the error is new, pass the ORIGINAL error to the logger. + * 3. Mark the error as reported to prevent duplicates. + * 4. Re-throw the error to allow it to bubble up and be decorated by other middleware. + */ +const reportingMiddleware = createMiddleware( + async ({ ctx, def, next }) => { + try { + return await next(); + } catch (error) { + const fnError = FnError.from(error); + + if (!fnError._reported) { + // --- Key Change: Logging the *original* cause --- + // If there's a cause, log that. Otherwise, log the error itself. + // This ensures the logger gets the error with the original stack trace. + const errorToLog = + fnError.cause instanceof Error ? fnError.cause : fnError; + ctx.logger.error(errorToLog, { + functionName: def.name, + message: 'An error was reported', + }); + + fnError.markAsReported(); + } + + // Re-throw the (potentially wrapped) error to allow the default + // error handler to build the `functionTrace`. + throw fnError; + } + } +); + +// --- The Test Suite --- + +describe('Robust Error Reporting with a Simple Logger', () => { + const logger = createSimpleLogger(); + + // Create a function factory with our realistic reporting middleware. + const createReportingFn = initCreateFn([reportingMiddleware]); + + // Define a set of nested functions. + const innerFn = createReportingFn({ + name: 'innerFn', + handler: () => { + throw createOriginalError('Critical failure in the core.'); + }, + }); + + const middleFn = createReportingFn({ + name: 'middleFn', + handler: async ({ ctx }) => { + return await innerFn({ ctx }); + }, + }); + + const outerFn = createReportingFn({ + name: 'outerFn', + handler: async ({ ctx }) => { + return await middleFn({ ctx }); + }, + }); + + beforeEach(() => { + logger.cleanup(); + }); + + test('should report the error exactly once', async () => { + await expect(outerFn({ ctx: { logger } })).rejects.toThrow(FnError); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + test('EMPIRICAL PROOF: The logger receives the ORIGINAL, untouched error object', async () => { + const originalError = createOriginalError('test error'); + const fnThatThrows = createReportingFn({ + name: 'fnThatThrows', + handler: () => { + throw originalError; + }, + }); + + await expect(fnThatThrows({ ctx: { logger } })).rejects.toThrow(); + + // --- THE KEY ASSERTION --- + // We check the first argument passed to our simple logger's `error` method. + // It should be the *exact same object* as the one we originally threw. + const loggedError = logger.error.mock.calls[0][0]; + expect(loggedError).toBe(originalError); + + // Verify its stack trace is the original one. + expect(loggedError.stack).toContain('createErrorFrame1'); + expect(loggedError.stack).toContain('createErrorFrame2'); + }); + + test('The final THROWN error contains the complete, ordered function trace for the caller', async () => { + const promise = outerFn({ ctx: { logger } }); + const caughtError = (await promise.catch((e) => e)) as FnError; + + // The final error that the user/caller catches should be the fully decorated FnError. + expect(caughtError).toBeInstanceOf(FnError); + + // The metadata should be from the outermost function that handled the error. + expect(caughtError.meta.functionName).toBe('outerFn'); + + // The functionTrace should show the full path the error bubbled up through. + expect(caughtError.meta.functionTrace).toEqual([ + 'outerFn', + 'middleFn', + 'innerFn', + ]); + }); + + test('The logged error and the final thrown error are different objects with different stacks', async () => { + // Run the function only ONCE and capture the thrown error. + const thrownError = (await outerFn({ ctx: { logger } }).catch( + (e) => e + )) as FnError; + + // Now, get the error that was logged during that single run. + const loggedError = logger.error.mock.calls[0][0] as Error; + + // The logged error is the original, the thrown error is the wrapper. + expect(loggedError).not.toBe(thrownError); + expect(thrownError.cause).toBe(loggedError); + + // Their stacks should be different. + const loggedStack = loggedError.stack; + const thrownStack = thrownError.stack; + + // console.log('loggedError', loggedError); + // console.log('--------------------------------'); + // console.log('thrownError', thrownError); + // console.log('--------------------------------'); + // console.log('loggedStack', loggedStack); + // console.log('--------------------------------'); + // console.log('thrownStack', thrownStack); + + expect(loggedStack).not.toEqual(thrownStack); + expect(loggedStack).toContain('createErrorFrame2'); // Original error's stack + expect(thrownStack).toMatch(/(?:Function|FnError)\.from/); // Wrapper's stack (frame name varies by V8 version) + }); +}); diff --git a/packages/fn/test/error.test.ts b/packages/fn/test/error.test.ts new file mode 100644 index 0000000..ecb3fef --- /dev/null +++ b/packages/fn/test/error.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect } from 'vitest'; +import { z } from 'zod'; +import { createFn, FnError } from '../src'; + +/** + * A helper to create an error with a distinct, deep stack trace for testing. + */ +const createOriginalError = (message: string): Error => { + try { + function createErrorFrame1() { + function createErrorFrame2() { + throw new Error(message); + } + createErrorFrame2(); + } + createErrorFrame1(); + } catch (e) { + return e as Error; + } + throw new Error('Fallback error'); // Should not be reached +}; + +describe('Core Error Handling', () => { + const basicFn = createFn({ + name: 'basicFn', + inputSchema: z.object({ id: z.string().min(1) }), + outputSchema: z.object({ success: z.boolean() }), + handler: ({ input }) => { + if (input.id === 'throw-raw') { + throw createOriginalError('Raw error from handler'); + } + if (input.id === 'throw-fn-error') { + throw new FnError({ + code: 'FORBIDDEN', + message: 'Custom FnError from handler', + }); + } + if (input.id === 'invalid-output') { + return { wrong: 'shape' } as any; + } + return { success: true }; + }, + }); + + test('direct call should throw FnError on input validation failure', async () => { + const promise = basicFn({ input: { id: '' } }); + await expect(promise).rejects.toThrow(FnError); + await expect(promise).rejects.toHaveProperty('code', 'INVALID_INPUT'); + }); + + test('direct call should throw FnError on input validation failure (alt)', async () => { + let error: any; + try { + await basicFn({ input: { id: '' } }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(FnError); + expect(error).toHaveProperty('code', 'INVALID_INPUT'); + }); + + test('direct call should throw FnError on output validation failure', async () => { + const promise = basicFn({ input: { id: 'invalid-output' } }); + await expect(promise).rejects.toThrow(FnError); + await expect(promise).rejects.toHaveProperty('code', 'INVALID_OUTPUT'); + }); + + test('direct call should throw FnError on output validation failure (alt)', async () => { + let error: any; + try { + await basicFn({ input: { id: 'invalid-output' } }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(FnError); + expect(error).toHaveProperty('code', 'INVALID_OUTPUT'); + }); + + test('direct call should wrap raw handler error in FnError and preserve cause', async () => { + const promise = basicFn({ input: { id: 'throw-raw' } }); + await expect(promise).rejects.toThrow(FnError); + + const caughtError = (await promise.catch((e) => e)) as FnError; + expect(caughtError.code).toBe('INTERNAL_SERVER_ERROR'); + expect(caughtError.message).toBe('Raw error from handler'); + + // Check that the original error is preserved as the cause + const cause = caughtError.cause; + expect(cause).toBeInstanceOf(Error); + if (cause instanceof Error) { + expect(cause.message).toBe('Raw error from handler'); + // Verify the original stack trace is present + expect(cause.stack).toContain('createErrorFrame2'); + } + }); + + test('direct call should preserve and enhance a thrown FnError', async () => { + const promise = basicFn({ input: { id: 'throw-fn-error' } }); + await expect(promise).rejects.toThrow(FnError); + + const caughtError = (await promise.catch((e) => e)) as FnError; + expect(caughtError.code).toBe('FORBIDDEN'); + expect(caughtError.message).toBe('Custom FnError from handler'); + // The default error handler should have added its own context + expect(caughtError.meta.functionName).toBe('basicFn'); + }); +}); diff --git a/packages/fn/test/test-utils.ts b/packages/fn/test/test-utils.ts new file mode 100644 index 0000000..70b114b --- /dev/null +++ b/packages/fn/test/test-utils.ts @@ -0,0 +1,118 @@ +// packages/fn/test/test-utils.ts +import { vi } from 'vitest'; +import { z } from 'zod'; +import { createMiddleware, FnError } from '../src'; + +// A mock logger to spy on calls +export const createMockLogger = () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + // Method to clear mocks between tests + cleanup: function () { + this.info.mockClear(); + this.error.mockClear(); + this.warn.mockClear(); + this.debug.mockClear(); + }, +}); + +// A mock DB client +export const mockDb = { + chat: { + create: vi.fn(async (data) => ({ id: 'chat_123', ...data.data })), + }, + credits: { + findFirst: vi.fn(async () => ({ amount: 100 })), + }, +}; + +// Define shared context types +export type ServerFnCtx = { + logger: ReturnType; + db: typeof mockDb; + user?: { id: string }; +}; +export type AuthedServerFnCtx = Required; + +// Shared Zod schemas +export const commonSchemas = { + createChatInput: z.object({ title: z.string() }), + createChatOutput: z.object({ id: z.string(), title: z.string() }), +}; + +// --- Reusable Middleware Helpers --- + +/** + * Creates a logging middleware that logs function entry and exit + */ +export const createLoggingMiddleware = () => + createMiddleware(async ({ ctx, input, def, next }) => { + ctx.logger.info(`-> Calling '${def.name}'`); + const result = await next(); + ctx.logger.info(`<- Finished '${def.name}'`); + return result; + }); + +/** + * Creates an error-handling logging middleware that also logs errors + */ +export const createErrorLoggingMiddleware = < + TContext extends { logger: any }, +>() => + createMiddleware(async ({ ctx, input, def, next }) => { + try { + ctx.logger.info(`-> Calling '${def.name}'`); + const result = await next(); + ctx.logger.info(`<- Finished '${def.name}'`); + return result; + } catch (error) { + ctx.logger.error(error, `Error in '${def.name}'`); + throw error; + } + }); + +/** + * Creates an auth middleware that requires a user to be present + */ +export const createAuthMiddleware = < + TContext extends { user?: { id: string } }, +>() => + createMiddleware(async ({ ctx, input, def, next }) => { + if (!ctx.user?.id) { + throw new FnError({ + code: 'UNAUTHORIZED', + message: 'User is not authenticated.', + }); + } + return next(); + }); + +/** + * Creates a simple timing middleware for performance testing + */ +export const createTimingMiddleware = () => + createMiddleware(async ({ ctx, input, def, next }) => { + const start = Date.now(); + const result = await next(); + const duration = Date.now() - start; + ctx.logger.info(`'${def.name}' took ${duration}ms`); + return result; + }); + +// --- Pre-configured Middleware Instances --- + +export const loggingMiddleware = createLoggingMiddleware(); +export const errorLoggingMiddleware = + createErrorLoggingMiddleware(); +export const authMiddleware = createAuthMiddleware(); +export const timingMiddleware = createTimingMiddleware(); + +// Authed versions +export const authedLoggingMiddleware = + createLoggingMiddleware(); +export const authedErrorLoggingMiddleware = + createErrorLoggingMiddleware(); +export const authedTimingMiddleware = + createTimingMiddleware(); diff --git a/packages/fn/test/tracing.test.ts b/packages/fn/test/tracing.test.ts new file mode 100644 index 0000000..fa5b381 --- /dev/null +++ b/packages/fn/test/tracing.test.ts @@ -0,0 +1,166 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import { createFn, createMiddleware, initCreateFn } from '../src'; + +// --- Test Setup: A Realistic Logger and Tracing Middleware --- + +/** + * A realistic, generic logger interface. + */ +interface ILogger { + info(message: string, ...data: any[]): void; + error(error: Error, ...data: any[]): void; + warn(message: string, ...data: any[]): void; + debug(message: string, ...data: any[]): void; + child(bindings: { name: string }): ILogger; +} + +/** + * A logger implementation that writes to the console and manages + * a hierarchical prefix for context. + */ +class ConsoleLogger implements ILogger { + private context: string; + + constructor(context: string = '') { + this.context = context; + } + + info(message: string, ...data: any[]): void { + console.info(this.context, message, ...data); + } + + error(error: Error, ...data: any[]): void { + console.error(this.context, error, ...data); + } + + warn(message: string, ...data: any[]): void { + console.warn(this.context, message, ...data); + } + + debug(message: string, ...data: any[]): void { + console.debug(this.context, message, ...data); + } + + child(bindings: { name: string }): ILogger { + const newContext = this.context + ? `${this.context} -> [${bindings.name}]` + : `[${bindings.name}]`; + return new ConsoleLogger(newContext); + } +} + +type LogContext = { + logger: ILogger; +}; + +/** + * A middleware specifically for tracing function execution. + * It uses the generic logger to output trace information. + */ +const tracingMiddleware = createMiddleware( + async ({ ctx, def, input, next }) => { + const childLogger = ctx.logger.child({ name: def.name }); + const startTime = Date.now(); + + childLogger.info('->', { input }); + + const result = await next({ ...ctx, logger: childLogger }); + const durationMs = Date.now() - startTime; + + childLogger.info('<-', { output: result, durationMs }); + return result; + } +); + +// --- The Test Suite --- + +describe('Hierarchical Tracing with a Console-Based Logger', () => { + // We will spy on console.info to capture log output. + const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + const createTracedFn = initCreateFn([tracingMiddleware]); + + // Define nested functions. + const innerFn = createTracedFn({ + name: 'innerFn', + handler: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { result: 'inner-ok' }; + }, + }); + + const middleFn = createTracedFn({ + name: 'middleFn', + handler: async ({ ctx }) => { + const innerResult = await innerFn({ ctx }); + return { result: `middle-ok-for-${innerResult.result}` }; + }, + }); + + const outerFn = createTracedFn({ + name: 'outerFn', + handler: async ({ ctx }) => { + const middleResult = await middleFn({ ctx }); + return { result: `outer-ok-for-${middleResult.result}` }; + }, + }); + + // Ensure the spy is reset before each test. + beforeEach(() => { + consoleSpy.mockClear(); + }); + + // Restore the original console.info after all tests. + afterEach(() => { + consoleSpy.mockRestore(); + }); + + test('should call console.info with hierarchical context and rich objects', async () => { + const rootLogger = new ConsoleLogger(); + await outerFn({ ctx: { logger: rootLogger } }); + + // Check that our logger was called the correct number of times. + expect(consoleSpy).toHaveBeenCalledTimes(6); + + // --- Assert Entry Traces --- + expect(consoleSpy).toHaveBeenNthCalledWith(1, '[outerFn]', '->', { + input: undefined, + }); + expect(consoleSpy).toHaveBeenNthCalledWith( + 2, + '[outerFn] -> [middleFn]', + '->', + { input: undefined } + ); + expect(consoleSpy).toHaveBeenNthCalledWith( + 3, + '[outerFn] -> [middleFn] -> [innerFn]', + '->', + { input: undefined } + ); + + // --- Assert Exit Traces --- + const innerExitCall = consoleSpy.mock.calls[3]; + expect(innerExitCall[0]).toBe('[outerFn] -> [middleFn] -> [innerFn]'); + expect(innerExitCall[1]).toBe('<-'); + expect(innerExitCall[2].output).toEqual({ result: 'inner-ok' }); + expect(innerExitCall[2].durationMs).toBeGreaterThanOrEqual(10); + + const middleExitCall = consoleSpy.mock.calls[4]; + expect(middleExitCall[0]).toBe('[outerFn] -> [middleFn]'); + expect(middleExitCall[1]).toBe('<-'); + expect(middleExitCall[2].output).toEqual({ + result: 'middle-ok-for-inner-ok', + }); + expect(middleExitCall[2].durationMs).toBeGreaterThanOrEqual(10); + + const outerExitCall = consoleSpy.mock.calls[5]; + expect(outerExitCall[0]).toBe('[outerFn]'); + expect(outerExitCall[1]).toBe('<-'); + expect(outerExitCall[2].output).toEqual({ + result: 'outer-ok-for-middle-ok-for-inner-ok', + }); + expect(outerExitCall[2].durationMs).toBeGreaterThanOrEqual(10); + }); +}); diff --git a/packages/fn/test/types.test.ts b/packages/fn/test/types.test.ts new file mode 100644 index 0000000..1189f5f --- /dev/null +++ b/packages/fn/test/types.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, expectTypeOf } from 'vitest'; +import { z } from 'zod'; +import { createFn, FnError, createMiddleware } from '../src/create-fn'; + +// Mock context for testing +type TestContext = { + user?: { id: string }; + db?: { + users: { + find: (id: string) => Promise<{ id: string; name: string }>; + }; + }; +}; + +describe('Type System', () => { + test('should infer types for handler with input and default context', () => { + const inputSchema = z.object({ id: z.string() }); + + const fn = createFn({ + name: 'test', + inputSchema, + handler: async ({ input, ctx }) => { + expectTypeOf(input).toEqualTypeOf<{ id: string }>(); + // expectTypeOf(ctx).toEqualTypeOf(); + return { success: true, id: input.id }; + }, + }); + + expectTypeOf(fn).parameter(0).toEqualTypeOf<{ + input: { id: string }; + ctx?: undefined; + }>(); + expectTypeOf(fn).returns.resolves.toEqualTypeOf<{ + success: boolean; + id: string; + }>(); + + expectTypeOf(fn.inputSchema).not.toBeUndefined(); + expectTypeOf(fn.outputSchema).toBeUndefined(); + + expectTypeOf(fn.inputSchema).toEqualTypeOf(); + }); + + test('should handle functions with no context and no input', () => { + const fn = createFn({ + name: 'ping', + handler: async ({ input, ctx }) => { + expectTypeOf(input).toEqualTypeOf(); + // expectTypeOf(ctx).toEqualTypeOf(); + return 'pong'; + }, + }); + + // Allows calling with no arguments at all + expectTypeOf(fn) + .parameter(0) + .toEqualTypeOf<{ input?: void; ctx?: undefined }>(); + expectTypeOf(fn).returns.resolves.toEqualTypeOf(); + }); +}); diff --git a/packages/fn/tsconfig.json b/packages/fn/tsconfig.json new file mode 100644 index 0000000..fa77e5b --- /dev/null +++ b/packages/fn/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@davstack/tsconfig/node14.json", + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "lib": ["ES2022", "dom", "dom.iterable"] + } +} diff --git a/packages/fn/tsup.config.ts b/packages/fn/tsup.config.ts new file mode 100644 index 0000000..675117a --- /dev/null +++ b/packages/fn/tsup.config.ts @@ -0,0 +1,8 @@ +// tsup.config.ts +import { defineConfig } from 'tsup'; + +export default defineConfig({ + dts: { + resolve: true, + }, +}); diff --git a/packages/init/src/skills/explore.md b/packages/init/src/skills/explore.md index a74d08b..f12a04a 100644 --- a/packages/init/src/skills/explore.md +++ b/packages/init/src/skills/explore.md @@ -12,11 +12,22 @@ description: >- Scope tightly, then run (backgrounded — the harness notifies you): - npx explore submit --file ~/.davstack/specs/.md + explore submit --file ~/.davstack/specs/.md + +When the current conversation already contains the real context, prefer compact +mode over writing a spec: + + explore submit --compact-mode "audit supervisor handoffs" + +Keep the inline compact prompt to roughly 5-10 words. Do not paste details, +requirements, quotes, or file lists into it; the compact spec-writer reads the +current transcript/history and distills that context for the executor. Trust the +subagent handoff unless the task is genuinely too ambiguous from conversation +history. For a **single scoped fact**, skip the spec file — inline it (no boilerplate): - npx explore submit 'Exact signature + return type of resolve_query_adapter backend/src/query/adapter.py only' + explore submit 'Exact signature + return type of resolve_query_adapter backend/src/query/adapter.py only' Many `--file` run in parallel from one command. Read the `result → ` file for the answer. diff --git a/packages/init/src/skills/fast-edit.md b/packages/init/src/skills/fast-edit.md index 9b4500a..f63e46c 100644 --- a/packages/init/src/skills/fast-edit.md +++ b/packages/init/src/skills/fast-edit.md @@ -15,6 +15,17 @@ Run (backgrounded — the harness notifies you): npx fast-edit submit --file ~/.davstack/specs/.md +When the current conversation already contains the real context, prefer compact +mode over writing a spec: + + npx fast-edit submit --compact-mode "rename legacy adapter" + +Keep the inline compact prompt to roughly 5-10 words. Do not paste details, +requirements, quotes, or file lists into it; the compact spec-writer reads the +current transcript/history and distills that context for the executor. Trust the +subagent handoff unless the edit is too risky or underspecified from +conversation history. + **Routing test.** Delegate when a *short* intent+constraints spec is enough for the executor to produce the **full** intended edit. If writing the spec would mean pasting the new file contents or spelling out every line, the spec diff --git a/packages/logs-server/CHANGELOG.md b/packages/logs-server/CHANGELOG.md index d41f6e1..edf66e0 100644 --- a/packages/logs-server/CHANGELOG.md +++ b/packages/logs-server/CHANGELOG.md @@ -1,5 +1,40 @@ # @davstack/logs-server +## 2.10.0 + +### Minor Changes + +- Add a `./sink` programmatic embedding entry point. + + `@davstack/logs-server/sink` re-exports the existing in-process ingest primitives (`openDb`, `handleIngest`, `dispatchIngest`, `insertLogs`, `selectByTrace`, `parseEnvelope`, `decodeBody`, plus the `LogRow`/`IngestResult`/`ParsedLog` types) so a consumer can persist Sentry telemetry to a sink SQLite file in-process — no HTTP daemon, no network. Purely additive: the CLI/daemon/server/ingest logic is unchanged; this is a new side-effect-free export surface only. + +## 2.9.1 + +### Patch Changes + +- 2ba15ff: Fix `view` reading web vitals from the wrong span in cross-runtime traces. + + The vitals header pulled `measurement.*` off the trace's topmost root span — but trace propagation re-parents the browser pageload (which actually carries the web vitals) _under_ the server root, so its segment isn't parentless and the header came up empty. `view` now scans for the segment carrying `measurement.*` rather than assuming it's the root, and rounds CLS to 4 d.p. So a browser↔server trace now renders e.g. `vitals: LCP 624ms · FCP 624ms · TTFB 364ms · CLS 0.0001`. + +## 2.9.0 + +### Minor Changes + +- df51308: Enrich telemetry rows with a `runtime` column and root-span transaction metadata. + + - **`runtime` column** (`browser | node | edge`): added to every row (spans, logs, events) with an idempotent `ALTER` migration. The shred can't recover the runtime from per-span fields once a transaction is expanded, so the consumer stamps it (Sentry `beforeSendLog` / `beforeSendTransaction`) and the parser persists it into a first-class column for trivial filtering (`WHERE runtime='browser'`) instead of digging through `attrs`. + - **Web vitals + request on the root span**: `transactionRows()` now reads `tx.measurements` (numeric web vitals → `attrs["measurement."]`, value-only: LCP/FCP/TTFB/INP/FID in ms, CLS unitless) and `tx.request` (→ `attrs["request.url"]` / `["request.method"]`), attaching them to the **root span row only** — no child duplication, no new columns. This is the "enriched root span" model (spans are the primitive; the segment/root span carries what the transaction used to), recovering the perf data the transaction→span shred otherwise drops. + - Docs: `reading-logs.md` gains a section on the root transaction metadata + a recursive-CTE query for reconstructing the span tree from the flat table. + +- 675d99b: Add a `view` CLI verb to render traces from the local sink. + + The viewer encodes the sink's own schema (the `runtime` column, `measurement.*` root-span attrs, the `kind ∈ span|log|event` enum, `parent_span_id` resolution, the transaction→span shred), so it lives in the package and ships versioned with that schema instead of being copied into each consuming repo. + + - `logs-server view ` — genuinely nested waterfall (DFS span tree via `parent_span_id`; logs slot under their enclosing span, orphans float at root by timestamp), with a web-vitals + request header read off the root span. + - `logs-server view --list [--n N]` — N most-recent traces with row/span/runtime summary. + - `logs-server view --cross [--n N]` — scan recent traces for ones spanning >1 runtime; the browser↔server trace-propagation feedback-loop command. + - Writes Markdown to `.davstack/view.md` by default (trace tables are wide and wrap badly in a terminal); `--stdout` prints instead. Flags: `--db` (accepts a bare session name → `.davstack/logs/.db`, or a path), `--ids`, `--limit`, `--out`. + ## 2.8.0 ### Minor Changes diff --git a/packages/logs-server/__tests__/bun-only/clean.test.ts b/packages/logs-server/__tests__/bun-only/clean.test.ts index a010078..f6a6fad 100644 --- a/packages/logs-server/__tests__/bun-only/clean.test.ts +++ b/packages/logs-server/__tests__/bun-only/clean.test.ts @@ -43,6 +43,7 @@ function row(over: Partial): LogRow { attrs: null, tag: null, duration_ms: null, + runtime: null, ...over, }; } diff --git a/packages/logs-server/__tests__/bun-only/db.test.ts b/packages/logs-server/__tests__/bun-only/db.test.ts index 53fd3a5..e7bce1c 100644 --- a/packages/logs-server/__tests__/bun-only/db.test.ts +++ b/packages/logs-server/__tests__/bun-only/db.test.ts @@ -24,6 +24,7 @@ function row(over: Partial): LogRow { attrs: null, tag: null, duration_ms: null, + runtime: null, ...over, }; } @@ -188,6 +189,68 @@ test('openDb migrates pre-2.7 schema: adds kind/duration_ms, existing rows → k } }); +test('openDb stores runtime column; it round-trips and absence is NULL', () => { + const db = openDb(':memory:'); + const cols = db.query('PRAGMA table_info(logs)').all() as { name: string }[]; + expect(cols.some((c) => c.name === 'runtime')).toBe(true); + insertLogs(db, [ + row({ msg: 'browser-row', runtime: 'browser' }), + row({ msg: 'node-row', runtime: 'node' }), + row({ msg: 'unstamped' }), // runtime defaults to null in row() + ]); + const got = db + .query('SELECT msg, runtime FROM logs ORDER BY id') + .all() as { msg: string; runtime: unknown }[]; + expect(got).toEqual([ + { msg: 'browser-row', runtime: 'browser' }, + { msg: 'node-row', runtime: 'node' }, + { msg: 'unstamped', runtime: null }, + ]); +}); + +test('openDb migrates pre-2.9 schema: adds runtime column, existing rows → NULL', () => { + // Hand-build a 2.8-era table (kind/duration_ms present, no runtime). The + // migration must add the column; existing rows stay NULL. + const { Database } = require('bun:sqlite'); + const legacy = new Database(':memory:'); + legacy.exec(`CREATE TABLE logs ( + id INTEGER PRIMARY KEY, ts REAL, recv_ts REAL, kind TEXT DEFAULT 'log', + project TEXT, service TEXT, run_id TEXT, trace_id TEXT, span_id TEXT, + level TEXT, severity_number INTEGER, logger TEXT, msg TEXT, data TEXT, + attrs TEXT, tag TEXT, duration_ms REAL + )`); + legacy.exec(`INSERT INTO logs (ts, recv_ts, kind, project, msg, data) VALUES + (1, 1, 'log', 'p', 'old-one', '{"body":"x"}')`); + const tmp = `/tmp/migrate-runtime-${process.pid}-${Date.now()}.db`; + legacy.exec(`VACUUM INTO '${tmp}'`); + legacy.close(); + + const db = openDb(tmp); + const cols = db.query('PRAGMA table_info(logs)').all() as { name: string }[]; + expect(cols.some((c) => c.name === 'runtime')).toBe(true); + const old = db.query("SELECT runtime FROM logs WHERE msg = 'old-one'").get() as { + runtime: unknown; + }; + expect(old.runtime).toBeNull(); + + // Idempotent — a second open is a no-op, and a freshly stamped row coexists. + db.close(); + const db2 = openDb(tmp); + insertLogs(db2, [row({ msg: 'new-row', runtime: 'edge', ts: 2, recv_ts: 2 })]); + const got = db2.query("SELECT runtime FROM logs WHERE msg = 'new-row'").get() as { + runtime: unknown; + }; + expect(got.runtime).toBe('edge'); + db2.close(); + for (const p of [tmp, `${tmp}-wal`, `${tmp}-shm`]) { + try { + require('node:fs').rmSync(p, { force: true }); + } catch { + /* temp file still locked — leave it for the OS */ + } + } +}); + test('insertLogs batch-inserts and rows are retrievable with autoincrement id', () => { const db = openDb(':memory:'); const n = insertLogs(db, [row({ msg: 'a' }), row({ msg: 'b' }), row({ msg: 'c' })]); diff --git a/packages/logs-server/__tests__/bun-only/view.test.ts b/packages/logs-server/__tests__/bun-only/view.test.ts new file mode 100644 index 0000000..d4b28b2 --- /dev/null +++ b/packages/logs-server/__tests__/bun-only/view.test.ts @@ -0,0 +1,237 @@ +// `view` renderer + pure helpers. Asserts the nesting, runtime labelling, and +// root-span vitals header that the trace viewer depends on, plus db-path +// resolution. Pure functions are tested directly; renderers run against an +// in-memory db seeded via the real insert path. + +import { test, expect } from 'bun:test'; +import { join } from 'node:path'; +import { openDb, insertLogs, type LogRow } from '../../src/db.js'; +import { + runtimeOf, + bareRuntime, + parentOf, + vitalsLine, + buildTree, + summarise, + renderTrace, + renderList, + renderCross, + resolveDbPath, + type ViewRow, +} from '../../src/view.js'; + +const T = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +function logRow(over: Partial): LogRow { + return { + ts: 1_700_000_000.0, + recv_ts: 1_700_000_000_000, + kind: 'log', + project: 'proj', + service: 'sentry.javascript.nextjs', + run_id: 'run-1', + trace_id: T, + span_id: 'bbbbbbbbbbbbbbbb', + level: 'info', + severity_number: 9, + logger: 'auto', + msg: 'hello', + data: '{}', + attrs: null, + tag: null, + duration_ms: null, + runtime: null, + ...over, + }; +} + +function viewRow(over: Partial): ViewRow { + return { kind: 'span', msg: '', attrs: null, data: null, duration_ms: null, ts: 1000, span_id: null, id: 1, ...over }; +} + +//* MARK: runtimeOf +test('runtimeOf trusts a stamped runtime over any heuristic', () => { + expect(runtimeOf({ runtime: 'edge', op: 'http.server' })).toBe('edge'); +}); + +test('runtimeOf falls back to a heuristic with a trailing ?', () => { + expect(runtimeOf({ op: 'http.server' })).toBe('node?'); + expect(runtimeOf({ op: 'pageload' })).toBe('browser?'); + expect(runtimeOf({})).toBe('?'); + expect(bareRuntime({ op: 'http.server' })).toBe('node'); + expect(bareRuntime({})).toBe('unknown'); +}); + +//* MARK: parentOf +test('parentOf reads parent_span_id from data, then attrs', () => { + expect(parentOf({ data: JSON.stringify({ parent_span_id: 'p1' }), attrs: null })).toBe('p1'); + expect(parentOf({ data: null, attrs: JSON.stringify({ 'sentry.parent_span_id': 'p2' }) })).toBe('p2'); + expect(parentOf({ data: 'not json', attrs: null })).toBe(''); +}); + +//* MARK: vitalsLine +test('vitalsLine formats web vitals (ms rounded, CLS unitless) + request', () => { + const line = vitalsLine({ 'measurement.lcp': 1234.6, 'measurement.cls': 0.04, 'request.method': 'GET', 'request.url': '/' }); + expect(line).toContain('LCP 1235ms'); + expect(line).toContain('CLS 0.04'); + expect(line).toContain('req: GET /'); +}); + +test('vitalsLine returns empty string when nothing present', () => { + expect(vitalsLine({})).toBe(''); +}); + +//* MARK: buildTree +test('buildTree nests children under parents and logs under their span', () => { + const rows: ViewRow[] = [ + viewRow({ kind: 'span', span_id: 'root', msg: 'root', ts: 1, id: 1 }), + viewRow({ kind: 'span', span_id: 'child', data: JSON.stringify({ parent_span_id: 'root' }), msg: 'child', ts: 2, id: 2 }), + viewRow({ kind: 'log', span_id: 'child', msg: 'log-under-child', ts: 3, id: 3 }), + ]; + const flat = buildTree(rows); + const byMsg = Object.fromEntries(flat.map((x) => [x.r.msg, x.depth])); + expect(byMsg['root']).toBe(0); + expect(byMsg['child']).toBe(1); + expect(byMsg['log-under-child']).toBe(2); +}); + +test('buildTree floats logs/spans with unresolvable parents at root', () => { + const rows: ViewRow[] = [ + viewRow({ kind: 'span', span_id: 'a', data: JSON.stringify({ parent_span_id: 'missing' }), msg: 'a', id: 1 }), + viewRow({ kind: 'log', span_id: 'nope', msg: 'orphan-log', id: 2 }), + ]; + const flat = buildTree(rows); + expect(flat.every((x) => x.depth === 0)).toBe(true); + expect(flat.map((x) => x.r.msg).sort()).toEqual(['a', 'orphan-log']); +}); + +test('buildTree does not loop on a parent cycle', () => { + const rows: ViewRow[] = [ + viewRow({ kind: 'span', span_id: 'x', data: JSON.stringify({ parent_span_id: 'y' }), msg: 'x', id: 1 }), + viewRow({ kind: 'span', span_id: 'y', data: JSON.stringify({ parent_span_id: 'x' }), msg: 'y', id: 2 }), + ]; + const flat = buildTree(rows); + expect(flat.length).toBe(2); // each visited once, no infinite recursion +}); + +//* MARK: summarise +test('summarise counts spans and collects distinct runtimes', () => { + const s = summarise([ + { kind: 'span', attrs: JSON.stringify({ runtime: 'node' }) }, + { kind: 'span', attrs: JSON.stringify({ runtime: 'browser' }) }, + { kind: 'log', attrs: JSON.stringify({ runtime: 'browser' }) }, + ]); + expect(s.spans).toBe(2); + expect(s.rows).toBe(3); + expect(s.runtimes).toEqual(['browser', 'node']); +}); + +//* MARK: renderTrace (against a real db) +function seedTrace(db: ReturnType) { + insertLogs(db, [ + logRow({ + kind: 'span', + span_id: 'root0000', + msg: 'GET /', + duration_ms: 100, + ts: 1000, + runtime: 'node', + attrs: JSON.stringify({ op: 'http.server', runtime: 'node', 'measurement.lcp': 1234.5, 'request.method': 'GET', 'request.url': '/' }), + }), + logRow({ + kind: 'span', + span_id: 'child000', + msg: 'getRaceResults', + duration_ms: 50, + ts: 1000.01, + runtime: 'node', + data: JSON.stringify({ parent_span_id: 'root0000' }), + attrs: JSON.stringify({ op: 'server-fn', runtime: 'node' }), + }), + logRow({ kind: 'log', span_id: 'child000', msg: 'hello:db', ts: 1000.02, runtime: 'node', attrs: JSON.stringify({ runtime: 'node' }) }), + ]); +} + +test('renderTrace nests children/logs and prints a vitals header from the root', () => { + const db = openDb(':memory:'); + seedTrace(db); + const md = renderTrace(db, T, 'default'); + expect(md).toContain('vitals: LCP 1235ms'); + expect(md).toContain('req: GET /'); + expect(md).toContain('└─'); // genuine nesting rendered + expect(md).toContain('getRaceResults'); + expect(md).toContain('hello:db'); + expect(md).toContain('node'); // runtime column +}); + +test('renderTrace surfaces vitals from a browser pageload nested under the server root', () => { + // Propagation re-parents the browser pageload (which carries measurement.*) + // under the node http.server root — so vitals are NOT on the trace root. + const db = openDb(':memory:'); + insertLogs(db, [ + logRow({ kind: 'span', span_id: 'srv00000', msg: 'GET /', duration_ms: 800, ts: 1000, runtime: 'node', attrs: JSON.stringify({ op: 'http.server', runtime: 'node' }) }), + logRow({ + kind: 'span', + span_id: 'pageload0', + msg: '/', + duration_ms: 1283, + ts: 1000.01, + runtime: 'browser', + data: JSON.stringify({ parent_span_id: 'srv00000' }), + attrs: JSON.stringify({ op: 'pageload', runtime: 'browser', 'measurement.lcp': 624, 'measurement.cls': 0.0001 }), + }), + ]); + const md = renderTrace(db, T, 'default'); + expect(md).toContain('LCP 624ms'); + expect(md).toContain('CLS 0.0001'); +}); + +test('renderTrace reports an empty db cleanly', () => { + const db = openDb(':memory:'); + expect(renderTrace(db, T, 'default')).toContain('no rows in db'); +}); + +//* MARK: renderList / renderCross +test('renderList summarises recent traces', () => { + const db = openDb(':memory:'); + seedTrace(db); + const md = renderList(db, 20, 'default'); + expect(md).toContain('most-recent traces'); + expect(md).toContain(T); +}); + +test('renderCross flags when no trace spans >1 runtime', () => { + const db = openDb(':memory:'); + seedTrace(db); // single-runtime (node) trace + expect(renderCross(db, 200, 'default')).toContain('NOT working yet'); +}); + +test('renderCross detects a browser↔node stitched trace', () => { + const db = openDb(':memory:'); + insertLogs(db, [ + logRow({ kind: 'span', span_id: 's1', msg: 'pageload', ts: 1, runtime: 'browser', attrs: JSON.stringify({ runtime: 'browser' }) }), + logRow({ kind: 'span', span_id: 's2', msg: 'GET /', ts: 2, runtime: 'node', attrs: JSON.stringify({ runtime: 'node' }) }), + ]); + const md = renderCross(db, 200, 'default'); + expect(md).toContain('stitch browser ↔ node'); +}); + +//* MARK: resolveDbPath +test('resolveDbPath maps a bare name to .davstack/logs/.db', () => { + const r = resolveDbPath({ db: 'session2', cwd: '/repo' }); + expect(r.name).toBe('session2'); + expect(r.path).toBe(join('/repo', '.davstack', 'logs', 'session2.db')); +}); + +test('resolveDbPath defaults to the repo default db', () => { + const r = resolveDbPath({ cwd: '/repo' }); + expect(r.name).toBe('default'); + expect(r.path).toBe(join('/repo', '.davstack', 'logs', 'default.db')); +}); + +test('resolveDbPath passes a path-like value straight through', () => { + const r = resolveDbPath({ db: 'tmp/custom.db', cwd: '/repo' }); + expect(r.name).toBe('custom'); + // `resolve` may prepend a drive on Windows; assert the tail, not the root. + expect(r.path.endsWith(join('repo', 'tmp', 'custom.db'))).toBe(true); +}); diff --git a/packages/logs-server/__tests__/envelope.test.ts b/packages/logs-server/__tests__/envelope.test.ts index f4ecfc1..e96eace 100644 --- a/packages/logs-server/__tests__/envelope.test.ts +++ b/packages/logs-server/__tests__/envelope.test.ts @@ -375,6 +375,82 @@ test('ISO-8601 string timestamps (python sentry_sdk) yield real ts + duration, n expect(rows[1].duration_ms).toBeCloseTo(20, 3); }); +//* MARK: Transaction metadata (web vitals + request) + +// Web vitals ride at the EVENT level (`event.measurements`), NOT on any span. +// The architecture stores transaction-level metadata ONCE on the ROOT span row +// (the segment); child spans never carry it. The key convention is +// `attrs["measurement."] = ` (value only, unit implied) and +// `attrs["request.url"] / ["request.method"]`. trace-view.ts reads these. + +const measurements = { + lcp: { value: 1234.5, unit: 'millisecond' }, + cls: { value: 0.01, unit: '' }, + fcp: { value: 800.2, unit: 'millisecond' }, + ttfb: { value: 120, unit: 'millisecond' }, + inp: { value: 80, unit: 'millisecond' }, +}; + +test('root row attrs carry web vitals from event.measurements (value extracted)', () => { + const { rows } = parseEnvelope(txEnvelope(transaction({ measurements }))); + const root = JSON.parse(rows[0].attrs as string) as Record; + expect(root['measurement.lcp']).toBe(1234.5); + expect(root['measurement.cls']).toBe(0.01); + expect(root['measurement.fcp']).toBe(800.2); + expect(root['measurement.ttfb']).toBe(120); + expect(root['measurement.inp']).toBe(80); + // the trace context's own data is preserved alongside the vitals + expect(root.op).toBe('http.server'); + expect(root['diag.project']).toBe('titanium'); +}); + +test('child span rows do NOT carry the transaction web vitals', () => { + const { rows } = parseEnvelope(txEnvelope(transaction({ measurements }))); + for (const child of rows.slice(1)) { + const a = JSON.parse(child.attrs as string) as Record; + expect(a['measurement.lcp']).toBeUndefined(); + expect(a['measurement.cls']).toBeUndefined(); + } +}); + +test('no measurements / no request ⇒ nothing added (back-compat baseline)', () => { + const { rows } = parseEnvelope(txEnvelope(transaction())); + const root = JSON.parse(rows[0].attrs as string) as Record; + expect(Object.keys(root).some((k) => k.startsWith('measurement.'))).toBe(false); + expect(root['request.url']).toBeUndefined(); + expect(root['request.method']).toBeUndefined(); +}); + +test('a measurement with a non-numeric value is skipped (never corrupts attrs)', () => { + const { rows } = parseEnvelope( + txEnvelope(transaction({ measurements: { lcp: { value: 'oops', unit: '' } } })), + ); + const root = JSON.parse(rows[0].attrs as string) as Record; + expect(root['measurement.lcp']).toBeUndefined(); +}); + +test('root row attrs capture request.url and request.method when present', () => { + const { rows } = parseEnvelope( + txEnvelope(transaction({ request: { url: 'https://app.test/dashboard', method: 'GET' } })), + ); + const root = JSON.parse(rows[0].attrs as string) as Record; + expect(root['request.url']).toBe('https://app.test/dashboard'); + expect(root['request.method']).toBe('GET'); + // and child spans still do not carry request metadata + const child = JSON.parse(rows[1].attrs as string) as Record; + expect(child['request.url']).toBeUndefined(); +}); + +test('vitals attach to the root even when the trace context had no data block', () => { + // A trace context with no `data` ⇒ root.attrs would be null without vitals. + // Merging must still produce a valid attrs object carrying the measurements. + const tx = transaction({ measurements: { lcp: { value: 999, unit: 'millisecond' } } }); + delete (tx.contexts.trace as Record).data; + const { rows } = parseEnvelope(txEnvelope(tx)); + const root = JSON.parse(rows[0].attrs as string) as Record; + expect(root['measurement.lcp']).toBe(999); +}); + //* MARK: Span tolerance // The sink's prime directive: never throw, never emit a corrupt row. These @@ -497,6 +573,52 @@ test('back-compat: a mixed envelope (log item + transaction item) yields both ki expect(logRow.duration_ms).toBeNull(); }); +//* MARK: Runtime + +// The `runtime` column records which Next.js runtime (browser|node|edge) +// emitted the row, stamped at the Sentry config source. Logs read it from +// `attributes.runtime`; spans from the span's plain `data.runtime`; events from +// `tags.runtime` (falling back to Sentry's native `contexts.runtime.name`). + +test('log runtime comes from attributes.runtime', () => { + const { rows } = parseEnvelope(envelope([log({ attributes: { runtime: a('browser') } })])); + expect(rows[0].runtime).toBe('browser'); +}); + +test('log runtime is null when attributes.runtime is absent', () => { + const { rows } = parseEnvelope(envelope([log()])); + expect(rows[0].runtime).toBeNull(); +}); + +test('span runtime comes from the span/trace plain data.runtime, root + children', () => { + const tx = transaction(); + (tx.contexts.trace.data as Record).runtime = 'node'; + (tx.spans[0].data as Record).runtime = 'node'; + (tx.spans[1].data as Record).runtime = 'node'; + const { rows } = parseEnvelope(txEnvelope(tx)); + expect(rows.every((r) => r.runtime === 'node')).toBe(true); +}); + +test('span runtime is null when data.runtime is absent', () => { + const { rows } = parseEnvelope(txEnvelope(transaction())); + expect(rows.every((r) => r.runtime === null)).toBe(true); +}); + +test('event runtime prefers tags.runtime', () => { + const ev = exceptionEvent({ tags: { runtime: 'edge' } }); + expect(parseEnvelope(eventEnvelope(ev)).rows[0].runtime).toBe('edge'); +}); + +test('event runtime falls back to contexts.runtime.name when no tag', () => { + // exceptionEvent() carries contexts.runtime = { name: 'node', ... } + expect(parseEnvelope(eventEnvelope(exceptionEvent())).rows[0].runtime).toBe('node'); +}); + +test('event runtime is null when neither tags.runtime nor contexts.runtime present', () => { + const bare = { event_id: 'deadbeef', level: 'info', timestamp: 1.0 }; + expect(parseEnvelope(eventEnvelope(bare)).rows[0].runtime).toBeNull(); +}); + //* MARK: Events (exceptions) // Real error/message events. Both fixtures below are the verbatim payloads diff --git a/packages/logs-server/docs/reading-logs.md b/packages/logs-server/docs/reading-logs.md index ade70ec..4b77f7a 100644 --- a/packages/logs-server/docs/reading-logs.md +++ b/packages/logs-server/docs/reading-logs.md @@ -8,6 +8,17 @@ sqlite3 -header -column .davstack/logs/default.db "" `-header` prints column names, `-column` aligns the output as a table. +## `sqlite3` vs `view` — when to write a file + +Default to **`sqlite3`** (or `logs-server view --stdout`) for your own investigation — +querying answers a question without leaving an artifact behind. The `view` command's +**file output (`.davstack/view.md`) is for handing a rendered trace to a human** to +read in their editor; reach for it only when you're actually presenting a trace, not +for every lookup. Don't generate a Markdown file per query — that litters the repo. + +- Agent self-service / a quick check → `sqlite3 …` or `view --stdout`. +- Showing a person a trace waterfall → `view ` (writes `.davstack/view.md`). + ## Sessions The daemon writes one file per "session" under `.davstack/logs/` based on --db tag: @@ -41,7 +52,10 @@ logs( msg TEXT, -- Sentry log body, ANSI prefix stripped data TEXT, -- raw Sentry log record JSON, verbatim attrs TEXT, -- flat {key: value} JSON, OTel {value,type} wrapper stripped (NULL when no attributes) - tag TEXT -- promoted from data.attributes['diag.tag'].value (nullable) + tag TEXT, -- promoted from data.attributes['diag.tag'].value (nullable) + kind TEXT, -- row discriminator: 'log' | 'span' | 'event' + duration_ms REAL, -- span headline metric ((end-start)*1000); NULL for logs + runtime TEXT -- emitting runtime ('browser'|'node'|'edge'), stamped by the consumer; NULL when absent ) ``` @@ -141,3 +155,70 @@ sqlite3 -header -column .davstack/logs/default.db " " ``` +## Spans: transaction metadata + the tree + +We do NOT duplicate transaction-level metadata onto every span. It is stored +ONCE on the **ROOT span row** (the segment — the row whose `parent_span_id` is +empty / not present in the trace), and nesting is reconstructed on demand. There +is no SQL view for the tree; this CTE (and the `logs-server view` CLI) IS +the view substitute. + +### Root-only metadata keys (in `attrs`) + +Stamped onto the root span row only, by `transactionRows()` in `src/envelope.ts`: + +| key | source | notes | +|------------------------|------------------------------|--------------------------------| +| `measurement.lcp` | `event.measurements.lcp` | web vital, **value only**, ms | +| `measurement.fcp` | `event.measurements.fcp` | ms | +| `measurement.ttfb` | `event.measurements.ttfb` | ms | +| `measurement.inp` | `event.measurements.inp` | ms | +| `measurement.cls` | `event.measurements.cls` | unitless | +| `measurement.` | `event.measurements.` | any vital Sentry sends | +| `request.url` | `event.request.url` | bug-repro context | +| `request.method` | `event.request.method` | bug-repro context | + +```bash +# Web vitals for a trace (read off the root span row) +sqlite3 -header -column .davstack/logs/default.db " + SELECT attrs->>'measurement.lcp' AS lcp_ms, + attrs->>'measurement.cls' AS cls, + attrs->>'measurement.inp' AS inp_ms, + attrs->>'request.url' AS url + FROM logs + WHERE kind = 'span' + AND trace_id = '' + AND COALESCE(attrs->>'parent_span_id','') = ''; +" +``` + +### Reconstruct the span tree (recursive CTE) + +`parent_span_id` is surfaced into `attrs` for each span. Walk it from the roots +to get depth + a materialised path, ordered depth-first — the same shape the +`logs-server view` waterfall renders. + +```bash +sqlite3 -header -column .davstack/logs/default.db " + WITH RECURSIVE tree AS ( + -- roots: spans with no resolvable parent in this trace + SELECT id, span_id, attrs->>'parent_span_id' AS parent, + 0 AS depth, printf('%010d', id) AS path, msg, duration_ms + FROM logs + WHERE kind = 'span' AND trace_id = '' + AND COALESCE(attrs->>'parent_span_id','') = '' + UNION ALL + SELECT c.id, c.span_id, c.attrs->>'parent_span_id', + t.depth + 1, t.path || '.' || printf('%010d', c.id), c.msg, c.duration_ms + FROM logs c + JOIN tree t ON c.attrs->>'parent_span_id' = t.span_id + WHERE c.kind = 'span' AND c.trace_id = '' + ) + SELECT depth, + substr(' ', 1, depth*2) || msg AS tree, + round(duration_ms) AS dur_ms + FROM tree + ORDER BY path; +" +``` + diff --git a/packages/logs-server/docs/telemetry-and-otel-strategy.md b/packages/logs-server/docs/telemetry-and-otel-strategy.md new file mode 100644 index 0000000..405c501 --- /dev/null +++ b/packages/logs-server/docs/telemetry-and-otel-strategy.md @@ -0,0 +1,220 @@ +# Telemetry package: naming + OpenTelemetry strategy + +Design note / decision record. Captures a brainstorm on (1) renaming `logs-server`, +(2) the Sentry↔OTel relationship and how close our store is to the OTel standard, +(3) whether we *should* move toward OTel, and (4) the viewer / ecosystem strategy. + +Status: **direction agreed, churn scope not yet committed.** This is a forward-looking +note, not yet implemented. + +--- + +## 0. What this package actually is + +A **local, Sentry-shaped telemetry sink**. It ingests Sentry envelopes over HTTP and +persists them to a per-repo SQLite file. One `logs` table holds three row kinds, +discriminated by a `kind` column: + +- `kind='log'` — log lines +- `kind='span'` — trace spans / transactions (with real `duration_ms`) +- `kind='event'` — error/exception events (full stacktrace kept verbatim) + +Key properties: +- **Optimized for coding agents** — one denormalized table queried with plain SQL. +- **Lossless** — the full original payload is kept verbatim in the `data` column; + `attrs` is an *additional* convenience projection (flattened key→value) for terse + `->>'key'` queries. Nothing is dropped. +- **Zero infra** — point your existing Sentry DSN at the local daemon; drop a `.db` + file to reset retention. + +--- + +## 1. Naming: `logs-server` → `telemetry` + +**Problem:** "logs" describes 1 of 3 kinds — it undersells and mis-describes the package. +The codebase had drifted into *three* competing vocabularies: +- **logs** — package/bin/`.davstack/logs/` dir/`logs` table/docs +- **diag** — `DIAG_*` env, `diag.*` attrs, `~/.claude/diag`, "the diag sink" comments +- **telemetry** — README + package.json prose + +**Resolution:** +- `diag` is **legacy** — it dates from when this was only a bug-diagnosis logger. + Diagnosis-specific behaviour already moved to the `diagnose` skill; the `diag.*` + bits are slated for cleanup/removal. So `diag` is **out** of the running. +- "logs" is factually wrong. **Pick one word, use it at every layer.** +- **Decision: `telemetry`.** It's the only word that's honest *and* collision-free at + every layer (package, dir, table, attr prefix) — it genuinely means "all the stuff + your app emits" and covers log+span+event without lying. + +**Words rejected and why:** +- `trace`/`traces` — collides with `trace_id` (everywhere) and repeats the `logs` sin + (implies spans only). +- `scope` — "Scope" is a core Sentry concept; collision in a Sentry-coupled tool. +- `otel` — **over-claims**: implies OTLP-native ingest (we ingest Sentry envelopes, not + OTLP) and our schema isn't OTLP-conformant anyway. Also a brand/category error (OTel is + instrumentation+protocol, not a backend/sink). Don't bake a false promise into the name. +- `activity` / `signals` — viable warmer/crisper roots (considered for the faint + "surveillance / phone-home" vibe of "telemetry"), but we stuck with `telemetry`. + +**Ambiguity (server vs viewer vs format):** solved by **role suffixes on a shared root**, +matching the existing `vitest-server` convention — not by abandoning the root: +- ingest daemon → `telemetry` (or `telemetry-server`) +- viewer → `telemetry-viewer` / a `telemetry view` subcommand +- possible future subcommands: `telemetry listen` / `telemetry view` + +**Surveillance vibe:** kill it in the tagline ("never leaves your machine"), since this +tool explicitly does *not* phone home. + +**Cost flagged (not yet committed):** it's a published package at v2.x. Renaming the npm +package + the `logs` table is a breaking major (3.0) with migration, and the table rename +ripples through every SQL recipe in the docs. Open question: package/bin/dir only, vs. +also rename the `logs` table. + +--- + +## 2. Sentry ↔ OTel: the relationship (clarifications) + +They are **not opposites.** Modern Sentry JS SDK (v8+) is **built on OpenTelemetry**, and +our ingested data is already OTel-shaped (OTel `severity_number 1..24`, log body + +attributes). We are already "doing OTel" — via a Sentry front door. + +Terminology (it's not a clean OTel-vs-OTLP split; OTLP is a *subset* of OTel): +- **OTel (umbrella)** = data model + **semantic conventions** + SDKs + protocol. + The "modeling vocabulary" = the data-model + semantic-conventions specs. +- **OTLP** = the wire protocol (HTTP/gRPC + protobuf/JSON) for transporting telemetry. +- **Collector** = a separate deployable pipeline binary (sampling/redaction/fan-out). +- **Sentry** = polished multi-language SDKs + a *proprietary envelope* wire format + + backend/UI. The "++" over raw OTel = better DX/docs, multi-language auto-instrumentation, + much better *error* tracking (mechanism/handled/stacktraces/error boundaries), and + extras like **Web Vitals** (`measurement.lcp` etc.). + +We chose Sentry as the **easy front door** and built our **own open local SQLite backend** +→ no vendor lock-in on storage (more in the open-source spirit than it feels). + +--- + +## 3. How close is our store to the OTel standard? + +**OTel-*inspired* and fully *correlatable*, but NOT OTLP-*conformant*.** + +Faithful to OTel: +- **Trace correlation via `trace_id`** across all signals — the core OTel principle, nailed. +- OTel severity model (`severity_number` 1..24; level→severity buckets match OTel exactly). +- `recv_ts` is exactly OTel's **ObservedTimestamp** concept (server-observed vs client time). +- All the OTel log primitives present (timestamp, trace/span ids, body, attributes). +- **Typed attributes ARE preserved** (verbatim in `data`); `attrs` is a bonus projection, + not a lossy replacement. + +Deliberate divergences (mostly product-right for our niche): +- **Signals merged into one table** (kind discriminator) — OTel keeps them separate. This + is the agent-optimization and it's correct for us. +- Resource flattened into columns (`service`/`project`/`runtime`) vs an OTel Resource object. +- Sentry span vocabulary (`op`/`status`/`description`) vs OTel `Name`/`Kind`/`StatusCode`. +- Semantic-convention attrs are *preserved* but not *normalized/enforced*. + +Because `data` is **lossless**, we can project to OTLP later without having lost anything. + +--- + +## 4. Should we move toward OTel? (strategy) + +Split the question: **OTel *modeling vocabulary* (cheap, helpful) vs OTLP *protocol + +Collector* (heavy, fights our value prop).** + +**Lean IN (cheap wins):** +- Preserve the semantic-convention attribute keys Sentry already emits (`http.*`, `db.*`) + instead of renaming to ad-hoc keys — makes a future viewer render generically. +- Keep typed attributes (already do) and trace-correlation (already do). + +**Do NOT adopt:** +- OTLP-as-our-storage protocol, or the Collector — production-aggregation infra that + breaks our "zero infra" pitch. +- A metrics-signal pipeline — **not needed.** `duration_ms` + SQL `GROUP BY` covers + local-dev "metrics" (p95, counts, error rates). Real OTel metrics shine in *production + fleet* monitoring, not local single-dev debugging. +- "Universal OTel sink/viewer" as a *goal* — that's a commodity space (Grafana, Jaeger); + it abandons our moat (simple, local, agent-first). + +**Keep our genuine advantages (do not sacrifice):** +- Single denormalized table + SQL → optimal for an LLM reader (`WHERE trace_id = ?` + returns the full interleaved timeline, no UNION/join). +- Zero infra (a sqlite file), one-line DSN setup, lossless `data`. +- **Web Vitals / `measurement.*` are a real Sentry "++", not a gap** — keep them; they + export to OTel as span attributes (OTel tools just won't special-render them). + +**Table shape note:** logs + spans + events are all "events on a timeline" → one table +fits them naturally. **Metrics** (if ever added) are aggregatable series → that would be +the natural *second* table. 3 tables wouldn't complicate infra, but buys nothing for +compatibility (the exporter reads any layout) and is worse for the headline agent query; +use **views** if per-signal ergonomics are ever wanted. + +--- + +## 5. Viewer / ecosystem strategy + +**Key fact:** otel-tui and otel-desktop-viewer are **OTLP *receivers*** — they listen on +the wire (gRPC 4317 / HTTP 4318), hold telemetry in memory, and render it. They do **not** +read a sqlite file. + +**Compatibility = a thin one-way OTLP *exporter* bridge** (cheap *because* `data` is +lossless): `davstack telemetry export --otlp localhost:4318` reads rows and replays them +as OTLP. This lights up otel-tui / Jaeger / Grafana **for free, with zero changes to our +storage or Sentry ingest path.** + +- Make it **on-demand / incremental-poll**, NOT a live second sink. (Two always-on sinks + is the bad practice to avoid.) Incremental export (`WHERE id > last_seen` every ~10s, + encode OTLP, POST) is trivially cheap — the poll cost is a non-issue. +- Steady-state footprint stays **one daemon**; the viewer is ephemeral. + +**Do NOT clone otel-tui into davstack tui.** davstack tui is **Ink + React + TypeScript**; +otel-tui is **Go**. "Clone" across that boundary = a rewrite, or maintaining a foreign Go +fork + shipping a second binary — exactly the custom-maintenance burden we want to avoid, +and it *still* wouldn't embed (Go TUI takes over the terminal). It also wouldn't give +topology (otel-tui doesn't do service maps) and renders our extras only generically. + +**Two integration levels, matched to language:** + +| Goal | Integrate by | How | +|---|---|---| +| Tap the ecosystem (Go tools: otel-tui/Jaeger/Grafana) | **protocol** | OTLP export; orchestrate as a subprocess (davstack tui "launch viewer" suspends Ink, hands over terminal, resumes) | +| Inside davstack tui + native rendering of *our* shape | **code** | a small native **Ink/React panel reading sqlite directly** — same language, actually embeddable | + +Resolving the confusion: the thing you can truly *embed* in davstack tui is a **native +panel** (not otel-tui — wrong language); the thing that *taps the ecosystem* is the **OTLP +export** (not embedding). Reserve "custom" for the gaps the ecosystem won't render. + +--- + +## 6. Topology (the one resource-model piece worth adopting) + +A service topology graph is **derivable from data we already capture**: +- **Nodes** = services. **Edges** = a span in service A with a child span in service B. +- We already have `trace_id` + `span_id` + `parent_span_id` (in attrs) + a per-row service. + +**The one gap = node identity.** Current `service` is `sdk.name` +(`sentry.javascript.node` vs `…react`) — enough to tell *browser from server*, but not +"api" from "worker" if both are Node. For real topology, capture a **logical +`service.name`** per app (set once per service via a Sentry tag/attribute) and promote it +to a column. Then: nodes = distinct service names; edges = cross-service parent/child spans; +render in the **native** viewer (off-the-shelf OTel TUIs won't do this). + +This is *selective* adoption: take the one resource field topology needs, skip the rest of +the resource spec. + +--- + +## 7. Recommended sequence + +1. **(Decision)** Rename `logs` → `telemetry` (root + role suffixes). Decide churn scope: + package/bin/dir only vs. also the `logs` table (breaking 3.0 + doc-recipe churn). +2. **Cheap OTel-vocabulary alignment**: preserve semantic-convention attr keys. +3. **Ship the OTLP export + poll bridge first** — smallest ecosystem win; validates the + lossless-`data` → OTLP thesis; davstack tui gets a "launch viewer" action. +4. **Build a native Ink panel only for the gaps** (topology, Web Vitals, error rows) — in + our own stack, where embedding is cheap and "custom" is justified. + +## Open questions +- Rename churn scope (table or not?). +- Whether to build the native viewer at all, or stop at the OTLP bridge. +- Final tagline wording (carry "OpenTelemetry-shaped, Sentry-easy, never leaves your + machine"). diff --git a/packages/logs-server/package.json b/packages/logs-server/package.json index dac2b5e..43d615d 100644 --- a/packages/logs-server/package.json +++ b/packages/logs-server/package.json @@ -1,6 +1,6 @@ { "name": "@davstack/logs-server", - "version": "2.8.0", + "version": "2.10.0", "license": "MIT", "type": "module", "description": "Local Sentry-shaped telemetry sink (logs + traces): ingest envelopes over HTTP, persist to per-repo SQLite. Read with sqlite3 against the logs table (kind discriminates log vs span; flat attrs column).", @@ -20,6 +20,10 @@ "./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" + }, + "./sink": { + "types": "./dist/sink.d.ts", + "default": "./dist/sink.js" } }, "files": [ @@ -36,6 +40,7 @@ "@davstack/cli-utils": "workspace:*" }, "devDependencies": { + "@types/bun": "^1.1.0", "@types/node": "^25.9.1", "tsup": "^8.0.0", "typescript": "^5.0.0" diff --git a/packages/logs-server/src/cli-spec.ts b/packages/logs-server/src/cli-spec.ts index 09a2b7b..38ddb7f 100644 --- a/packages/logs-server/src/cli-spec.ts +++ b/packages/logs-server/src/cli-spec.ts @@ -253,6 +253,36 @@ export const cliSpec: CliSpec = { } }, }, + view: { + description: + 'Render one trace as a nested waterfall, or scan recent traces (--list / --cross for cross-runtime propagation). Writes Markdown to .davstack/view.md (wide tables wrap badly in a terminal); --stdout to print instead.', + positionals: [{ name: 'trace_id', required: false, description: 'Trace to render (omit with --list / --cross)' }], + flags: { + db: dbFlag(), + list: { type: 'boolean', default: false, description: 'List N most-recent traces' }, + cross: { type: 'boolean', default: false, description: 'Scan recent traces for ones spanning >1 runtime' }, + n: { type: 'number', description: 'Count for --list (default 20) / --cross (default 200)' }, + limit: { type: 'number', description: 'Cap rows in a waterfall' }, + ids: { type: 'boolean', default: false, description: 'Add span_id / parent columns (propagation debugging)' }, + out: { type: 'string', description: 'Output path (default .davstack/view.md)' }, + stdout: { type: 'boolean', default: false, description: 'Print to stdout instead of writing a file' }, + }, + run: async (ctx) => { + const { runView } = await import('./view.js'); + return runView({ + trace: ctx.positionals[0], + list: ctx.flags.list as boolean, + cross: ctx.flags.cross as boolean, + n: ctx.flags.n as number | undefined, + limit: ctx.flags.limit as number | undefined, + ids: ctx.flags.ids as boolean, + out: ctx.flags.out as string | undefined, + stdout: ctx.flags.stdout as boolean, + db: ctx.flags.db as string | undefined, + cwd: process.cwd(), + }); + }, + }, doctor: doctorSpec, check: { ...doctorSpec, diff --git a/packages/logs-server/src/db.ts b/packages/logs-server/src/db.ts index 3a8d2c6..f372a8c 100644 --- a/packages/logs-server/src/db.ts +++ b/packages/logs-server/src/db.ts @@ -22,6 +22,7 @@ export type LogRow = { attrs: string | null; // flat key→value JSON (NULL when none). Spans add op/status/parent_span_id/description/duration_ms. tag: string | null; // diag.tag attribution (optional) duration_ms: number | null; // span headline metric ((timestamp - start_timestamp)*1000); NULL for logs + runtime: string | null; // emitting Next.js runtime (browser|node|edge), stamped at the Sentry config; NULL when absent }; const COLS: (keyof LogRow)[] = [ @@ -41,6 +42,7 @@ const COLS: (keyof LogRow)[] = [ 'attrs', 'tag', 'duration_ms', + 'runtime', ]; // Migrate pre-2.2 schemas: add the `attrs` column if missing, backfill from @@ -102,6 +104,24 @@ function migrateKindColumns(db: Database): void { } } +// Migrate pre-2.9 schemas: add the free-text `runtime` column if missing. It +// records which Next.js runtime (browser|node|edge) emitted the row, stamped at +// the Sentry config source. Legacy rows stay NULL (the runtime split didn't +// exist when they were written). Wrapped in BEGIN IMMEDIATE so partial state +// can't leak; idempotent (guarded by the table_info check). +function migrateRuntimeColumn(db: Database): void { + const cols = db.query('PRAGMA table_info(logs)').all() as { name: string }[]; + if (cols.some((c) => c.name === 'runtime')) return; + db.exec('BEGIN IMMEDIATE'); + try { + db.exec('ALTER TABLE logs ADD COLUMN runtime TEXT'); + db.exec('COMMIT'); + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } +} + export function openDb(path: string): Database { const db = new Database(path); db.exec('PRAGMA journal_mode = WAL'); // concurrent hammer-ingest + query @@ -123,7 +143,8 @@ export function openDb(path: string): Database { data TEXT, attrs TEXT, tag TEXT, - duration_ms REAL + duration_ms REAL, + runtime TEXT )`); db.exec( `CREATE INDEX IF NOT EXISTS idx_logs_corr @@ -131,6 +152,7 @@ export function openDb(path: string): Database { ); migrateAttrsColumn(db); migrateKindColumns(db); + migrateRuntimeColumn(db); return db; } diff --git a/packages/logs-server/src/envelope.ts b/packages/logs-server/src/envelope.ts index 50d3aac..7d9cb4e 100644 --- a/packages/logs-server/src/envelope.ts +++ b/packages/logs-server/src/envelope.ts @@ -54,6 +54,12 @@ function strOr(v: unknown, d: string): string { return v === undefined || v === null ? d : String(v); } +// Like strOr but yields null (not a default) for absent values — for nullable +// columns (e.g. `runtime`) where absence must round-trip as SQL NULL. +function strOrNull(v: unknown): string | null { + return v === undefined || v === null ? null : String(v); +} + // Strip a leading ANSI styled prefix shaped like `\x1b[