@blueforge-studio/app-dev-db
SDK for app-dev-db, the BlueForge readings store: a producer client to push test.run / audit.score / flows.verify readings and a reader client for catalog, latest, series and runs. Zero runtime dependencies.
Información del paquete
Versiones
| Versión | Publicada |
|---|---|
| 0.2.0 | 2026-10-29 |
| 0.1.0 | 2026-10-11 |
Cadena de suministro por versión
| Versión | Firma | SBOM | Att |
|---|---|---|---|
| 0.2.0 | |||
| 0.1.0 |
Haz clic en una versión para ver sus detalles.
Cadena de suministro
No hay nada registrado para 0.2.0. Las versiones publicadas antes de que el registro empezara a escribir la procedencia no la tienen, y las firmas y los SBOM los adjunta quien publica.
Dependencias
README
@blueforge-studio/app-dev-db
SDK for app-dev-db, the BlueForge readings store. A repo's CI pushes readings (test.run, audit.score,
flows.verify) and other apps read them back (catalog, latest, series, runs), without hand-rolled fetch.
- Two clients in one package:
createProducer(write) andcreateReader(read). - Zero runtime dependencies beyond the platform
fetch(Node 20+, browsers, edge runtimes). - Server-side only. Both clients hold a secret; never put one in browser code.
Operator side (minting secrets, onboarding a producer or reader, rotation): docs/runbooks/app-dev-db.md in forge-control.
Install
pnpm add @blueforge-studio/app-dev-db # registry: https://api.packr.blueforge.studio (needs PACKR_TOKEN)
Producer: push a reading from CI
Report main-branch pushes only. The read side treats only the default branch as "current", and a PR run has no stable ref. Gate the step on a push to main:
# .gitea/workflows/ci.yml
- name: Report test run to app-dev-db
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: node scripts/report-readings.mjs
env:
APP_DEVDB_URL: ${{ vars.APP_DEVDB_URL }} # the FULL ingest URL, .../api/app-dev-db/ingest
APP_DEVDB_PROJECT: ${{ vars.APP_DEVDB_PROJECT }} # the project UUID, not the slug
APP_DEVDB_SECRET: ${{ secrets.APP_DEVDB_PRODUCER_SECRET }}
// scripts/report-readings.mjs
import { readFileSync, existsSync } from 'node:fs'
import { createProducer, testRunFromVitestReport, AppDevDbApiError } from '@blueforge-studio/app-dev-db'
const producer = createProducer({
ingestUrl: process.env.APP_DEVDB_URL,
secret: process.env.APP_DEVDB_SECRET,
project: process.env.APP_DEVDB_PROJECT,
repo: 'taskmaster',
producer: 'gitea-actions:taskmaster',
})
// vitest --reporter=json --outputFile.json=vitest-report.json
const report = existsSync('vitest-report.json') && JSON.parse(readFileSync('vitest-report.json', 'utf8'))
const payload = report && testRunFromVitestReport(report, { suite: 'unit' })
if (!payload) {
console.log('no tests ran: nothing to report') // skipped, never pushed as a zero
} else {
try {
const out = await producer.push('test.run', payload, {
ref: process.env.GITHUB_REF_NAME ?? 'main',
commit: process.env.GITHUB_SHA,
source: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
})
console.log(out.deduped ? 'already stored' : 'stored', out.contentHash)
} catch (e) {
// Telemetry must not stop a deploy: our payload being wrong (4xx) is ours to fix, an unreachable plane is not.
console.error(String(e))
process.exitCode = e instanceof AppDevDbApiError && [400, 413, 422].includes(e.status) ? 1 : 0
}
}
push(kind, payload, options):
| Option | Meaning |
|---|---|
ref (required) | branch / tag name or sha |
refKind | branch (default), tag, sha |
commit | 7-64 hex chars; part of the idempotency hash, so pass it |
workspace | workspace slug (trimmed, lowercased); omit for a workspace-less app |
measuredAt | Date or ISO-8601 string with an offset; default now. The server rejects more than 5 minutes ahead of its clock (measured_ahead) |
source | where the reading came from (a CI run URL); default git:<commit>, else the producer id |
Ingest is idempotent on commit + payload content: a retried or re-run push returns deduped: true.
Payloads
| Kind | Payload | Refused client-side when |
|---|---|---|
test.run | { suite, passed, failed, skipped, duration_ms } | passed + failed + skipped < 1 |
audit.score | { gate, score, floor, checked } | checked < 1 |
flows.verify | { files_checked, violations, warnings, declared_drafts } | files_checked < 1 |
sidecar.audit | { product, score (0-10), families: {name: boolean}, counts: {name: n}, dangling_links } | families has no keys |
Counts must be non-negative integers; score / floor finite numbers; unknown fields are rejected (the server is strict too).
Only push what was measured. A dimension you could not measure is skipped, never pushed as 0: an empty scope throws
AppDevDbClientError with code: 'empty_scope' before any request, and the server's 422 empty_scope is the backstop.
testRunFromVitestReport(report, { suite }) (also importable from @blueforge-studio/app-dev-db/vitest) reads counts from a
vitest JSON report: numPassedTests, numFailedTests, and numPendingTests + numTodoTests as skipped. It returns null when
the run measured no tests (skip the push) and throws on a malformed report; a missing or non-numeric count is never read as 0.
Reader: show a project's current pass rate and freshness
The taskmaster case: a project-management view that shows how healthy a project's tests are, and how much to trust that number.
import { createReader, AppDevDbApiError } from '@blueforge-studio/app-dev-db'
const reader = createReader({
baseUrl: process.env.APP_DEVDB_BASE_URL, // the mission-control BASE url, not the ingest url
secret: process.env.APP_DEVDB_READER_SECRET,
})
type Health =
| { state: 'unknown'; reason: string } // nothing measured: NOT 0%
| { state: 'no-rate' } // measured, but only skips: no pass rate exists
| { state: 'known'; passRate: number; stale: boolean; ageHours: number }
export async function testHealth(projectSlug: string): Promise<Health | 'not-found'> {
try {
const res = await reader.latest({ project: projectSlug, kind: 'test.run' })
if (res.status === 'unknown') return { state: 'unknown', reason: res.reason }
// One reading per suite; the project's rate is passed / (passed + failed) over all of them.
let passed = 0, failed = 0
for (const r of res.readings) { passed += r.derived.passed; failed += r.derived.failed }
if (passed + failed === 0) return { state: 'no-rate' }
const stale = res.readings.some((r) => r.freshness === 'stale')
return { state: 'known', passRate: passed / (passed + failed), stale, ageHours: Math.max(...res.readings.map((r) => r.ageHours)) }
} catch (e) {
if (e instanceof AppDevDbApiError && e.status === 404) return 'not-found' // missing and not-yours look identical, by design
throw e
}
}
Reads default to the project's default branch. ref: 'release/1.0' (with optional repo, refKind) pins one ref;
allRefs: true widens to every ref, newest wins. Every ref-scoped response says which it used (refSelection, ref, defaultBranch).
| Method | Returns |
|---|---|
catalog({ project? }) | what exists per project/kind/series, across every ref (an inventory, not "current") |
latest({ project, kind?, series?, ...ref }) | newest reading per kind/series/workspace on the selected ref, with freshness; status: 'unknown' when none |
series({ project, kind, series?, window?, step?, ...ref }) | bucketed history (window like 7d, step: hour | day | week) |
runs({ project, kind, series?, window?, limit?, before?, ...ref }) | one page of raw readings, newest first, plus a next cursor |
runsPages(q) / iterateRuns(q) | async iterators over every page / every row, following next |
for await (const row of reader.iterateRuns({ project: 'taskmaster', kind: 'test.run', window: '7d' })) {
console.log(row.measuredAt, row.derived.passRate)
}
Unknown is not zero
Never turn an absent or unknown reading into 0.
latestwith nothing stored returnsstatus: 'unknown'(freshness: 'undeclared',readings: []). The SDK passes it through unchanged; it is a distinct type, not an empty success. Branch onres.statusbefore readingreadings. Do not render it as0%, "all green" or "0 failures".derived.passRateisnullwhen nothing passed or failed (every test skipped). That is "no rate", not0. A count of0(passed: 0) is a real measurement; anullrate is not.seriesover a window with no readings is an emptypointslist; do not zero-fill it.freshness: 'stale'means the newest reading is old (7 days until per-series cadence exists). The value is still the last measurement; show it as stale, not as current.- "Missing" and "not yours" are the same
404: do not try to tell them apart.
Errors
Every failure is a typed error. None ever contains the secret: server text and lower-level errors are redacted, and the clients
expose no secret through toString, JSON.stringify or util.inspect.
| Class | When | Fields |
|---|---|---|
AppDevDbClientError | refused locally, no request made | code: empty_scope | invalid_input |
AppDevDbApiError | the server answered non-2xx | code, status, message, details? |
AppDevDbNetworkError | no answer after retries, or an unreadable 2xx | attempts |
AppDevDbApiError.code:
code | HTTP | Meaning | Retried? |
|---|---|---|---|
invalid_request | 400 | schema, unknown kind, bad ref/workspace/params (details has the issues) | no |
measured_ahead | 400 | measured_at more than 5 minutes ahead of the server clock | no |
unauthorized | 401 | missing/unknown secret (a producer secret does not read, a reader secret does not write) | no |
tenant_mismatch | 403 | the producer is bound to a different tenant than the project | no |
unknown_project | 404 | ingest: the project UUID does not resolve | no |
not_found | 404 | read: no such project or not on your list (identical on purpose; the wire body has no code) | no |
ambiguous_slug | 409 | the project slug is not globally unique | no |
payload_too_large | 413 | payload over 256 KB | no |
empty_scope | 422 | the reading measured nothing | no |
internal_error | 500 | unexpected server failure | no |
not_configured | 503 | the producer/reader directory is unset or invalid (fail-closed) | yes |
unknown | any | an unrecognised status or a non-JSON body | only 502/503/504 |
Retry policy: only network errors and 502 / 503 / 504, with exponential backoff (250 ms, 500 ms, ... capped at 4 s)
and 2 retries by default (retry: { retries, baseDelayMs, maxDelayMs }). A 4xx is never retried. Each attempt has a 15 s
timeout (timeoutMs, 0 disables). Pass fetch to inject your own, e.g. in tests.
Versioning
0.x: the wire contract is additive-only (a shipped reading kind is never removed or redefined), so new kinds arrive as
new entries in READING_KINDS; the SDK's types and clients may still change between minors until 1.0.