@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.

Package Info

Latest Version0.2.0
Licensenone
Keywords-
Repository-
Author-
Homepage-
Bugs-

Versions

VersionPublished
0.2.02026-10-29
0.1.02026-10-11

Per-version supply chain

VersionSigSBOMAtt
0.2.0
0.1.0

Click a version to view its details.

Supply chain

Provenance

Published by
web:5
When
2026-10-29T14:37:51Z
sha256 (registry-computed)
856a35979cf455a53c1004c94dc72c6e3dd569cde1d2df750fb76e181c2a47dd

Observed by the registry at publish.

Claimed by the build

The publisher claimed nothing about its build.

Attachments

Signature
none
SBOM
none

Dependencies

Loading dependency graph...

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) and createReader (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):

OptionMeaning
ref (required)branch / tag name or sha
refKindbranch (default), tag, sha
commit7-64 hex chars; part of the idempotency hash, so pass it
workspaceworkspace slug (trimmed, lowercased); omit for a workspace-less app
measuredAtDate or ISO-8601 string with an offset; default now. The server rejects more than 5 minutes ahead of its clock (measured_ahead)
sourcewhere 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

KindPayloadRefused 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).

MethodReturns
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.

  • latest with nothing stored returns status: 'unknown' (freshness: 'undeclared', readings: []). The SDK passes it through unchanged; it is a distinct type, not an empty success. Branch on res.status before reading readings. Do not render it as 0%, "all green" or "0 failures".
  • derived.passRate is null when nothing passed or failed (every test skipped). That is "no rate", not 0. A count of 0 (passed: 0) is a real measurement; a null rate is not.
  • series over a window with no readings is an empty points list; 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.

ClassWhenFields
AppDevDbClientErrorrefused locally, no request madecode: empty_scope | invalid_input
AppDevDbApiErrorthe server answered non-2xxcode, status, message, details?
AppDevDbNetworkErrorno answer after retries, or an unreadable 2xxattempts

AppDevDbApiError.code:

codeHTTPMeaningRetried?
invalid_request400schema, unknown kind, bad ref/workspace/params (details has the issues)no
measured_ahead400measured_at more than 5 minutes ahead of the server clockno
unauthorized401missing/unknown secret (a producer secret does not read, a reader secret does not write)no
tenant_mismatch403the producer is bound to a different tenant than the projectno
unknown_project404ingest: the project UUID does not resolveno
not_found404read: no such project or not on your list (identical on purpose; the wire body has no code)no
ambiguous_slug409the project slug is not globally uniqueno
payload_too_large413payload over 256 KBno
empty_scope422the reading measured nothingno
internal_error500unexpected server failureno
not_configured503the producer/reader directory is unset or invalid (fail-closed)yes
unknownanyan unrecognised status or a non-JSON bodyonly 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.