@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

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

Versiones

VersiónPublicada
0.2.02026-10-29
0.1.02026-10-11

Cadena de suministro por versión

VersiónFirmaSBOMAtt
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

Cargando el grafo de 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) 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.