import { StoreCapabilities, NoydbStore } from '@noy-db/hub/to'; import { SyncTargetRole } from '@noy-db/hub'; /** * Shared types for the store-probe diagnostics (absorbed into `@noy-db/to-meter`, #845). * * Both `runStoreProbe()` and `probeTopology()` produce structured * reports with the same vocabulary: a fixed set of per-axis measurement * blocks, a `ProbeRisk[]` list with severity and a machine-readable * `code`, and a `SuitabilityScore` triple (primary / sync-peer / backup) * summarising whether the store is safe to use in that role. * * The `code` strings are the identifiers adopters pass to * `createNoydb({ acknowledgeRisks: [...] })` to silence a known risk. * * @module */ /** Role a store is being considered for. */ type ProbeRole = 'primary' | 'sync-peer' | 'backup' | 'archive'; /** Machine-readable risk identifiers. Keep this list closed — adopters * pass these exact strings to `acknowledgeRisks`. */ type ProbeRiskCode = 'slow-write-p99' | 'slow-hydration' | 'slow-sync' | 'cas-mismatch' | 'cas-unsupported' | 'no-ping' | 'hydration-blocked' | 'bundle-as-sync-peer' | 'no-atomic-cas-sync-peer' | 'primary-slower-than-peer' | 'archive-pull-configured'; interface ProbeRisk { readonly code: ProbeRiskCode; readonly severity: 'warn' | 'error'; readonly message: string; } /** Per-axis latency measurement — all numbers in milliseconds. */ interface LatencyStats { readonly count: number; readonly p50: number; readonly p99: number; readonly max: number; } interface WriteAxis { readonly serial: LatencyStats; readonly concurrent: LatencyStats; readonly coldStart: number; } interface CasAxis { readonly concurrent: number; readonly successes: number; readonly rejections: number; readonly expected: 'exactly-one' | 'multiple-ok'; } interface HydrationAxis { readonly records: number; readonly loadAllMs: number; readonly perRecordBytes: number; readonly totalBytes: number; } interface SyncAxis { readonly singlePushMs: number; readonly batchPushMs: number; readonly batchSize: number; readonly bytesPerPush: number; } interface NetworkAxis { readonly pingSupported: boolean; readonly pingMs: number | null; } /** Suitability decision per role. */ interface SuitabilityScore { /** Roles the store passes (no error-severity risks apply). */ readonly recommended: readonly ProbeRole[]; /** Risks that caller may choose to acknowledge. */ readonly risks: readonly ProbeRisk[]; } /** Full report produced by `runStoreProbe()`. */ interface StoreProbeReport { readonly store: string; readonly capabilities: StoreCapabilities | null; readonly write: WriteAxis; readonly cas: CasAxis; readonly hydration: HydrationAxis; readonly sync: SyncAxis; readonly network: NetworkAxis; readonly suitability: SuitabilityScore; readonly durationMs: number; readonly probedAt: string; } /** Options for `runStoreProbe()`. */ interface ProbeOptions { /** * Probe vault name. Isolated from real data — cleaned up at the * end of the probe. Default `'probe-vault'`. Avoid `_`-prefixed * values: several stores hide `_`-collections from `loadAll`, * which would make D3 (hydration) measure zero records. */ readonly vault?: string; /** * Collection used for probe writes. Default `'probe-benchmark'`. * Leftover envelopes may persist if the probe is interrupted — * adopters can safely delete anything under this name. */ readonly collection?: string; /** * Declared capabilities of the store (for `casAtomic` verification). * Stores in this codebase don't attach capabilities to the `NoydbStore` * object itself — pass them explicitly so the probe can compare * declared vs. measured behaviour. */ readonly capabilities?: StoreCapabilities; /** Number of serial writes in the D1 latency sample. Default 20. */ readonly writeSampleSize?: number; /** Number of parallel writers in the D2 CAS test. Default 10. */ readonly casConcurrency?: number; /** Records to populate before measuring loadAll. Default 100. */ readonly hydrationRecords?: number; /** Batch size for D4 sync economics. Default 50. */ readonly syncBatchSize?: number; /** p99 write-latency threshold (ms). Above this → `slow-write-p99`. Default 100. */ readonly slowWriteMs?: number; /** loadAll threshold (ms). Above this → `slow-hydration`. Default 500. */ readonly slowHydrationMs?: number; /** Single-record push threshold (ms). Above this → `slow-sync`. Default 250. */ readonly slowSyncMs?: number; } /** Input for `probeTopology()`. */ interface TopologyProbeOptions extends ProbeOptions { readonly store: NoydbStore; readonly sync?: ReadonlyArray<{ readonly store: NoydbStore; readonly role: SyncTargetRole; readonly label?: string; readonly hasPullPolicy?: boolean; }>; /** Expected number of concurrent human users. Default 1. */ readonly expectedUsers?: number; } interface TopologyRisk extends ProbeRisk { /** Target label (or 'primary'). */ readonly target: string; } interface TopologyTargetReport extends StoreProbeReport { readonly role: SyncTargetRole; readonly label: string; } interface TopologyProbeReport { readonly primary: StoreProbeReport; readonly targets: readonly TopologyTargetReport[]; readonly topology: readonly TopologyRisk[]; /** `true` iff there are no error-severity risks across primary + targets + topology. */ readonly recommended: boolean; readonly durationMs: number; readonly probedAt: string; } /** * `runStoreProbe()` — setup-time suitability test for a `NoydbStore`. * * Five measurement axes (D1-D5 per spec in issue ): * * | Axis | Measures | * |------|----------| * | D1 — Write responsiveness | serial + concurrent put p50/p99, cold-start | * | D2 — Conflict integrity | N parallel puts with same `expectedVersion` | * | D3 — Hydration cost | `loadAll()` time and record-size footprint | * | D4 — Sync economics | single + batch `put` cost, bytes/push | * | D5 — Network resilience | `ping()` support + latency | * * Writes happen to an isolated `_probe / _probe` collection that the * probe cleans up on completion. The probe does not mutate real * application data — but if a probe is interrupted, stray envelopes * may remain under that collection. Adopters can safely delete * anything under the `_probe` vault. * * The probe never decrypts anything. It operates at the `NoydbStore` * layer with handcrafted {@link EncryptedEnvelope}-shaped payloads — a * probe run produces no keyring, no DEK, and no plaintext the store * can see. * * @module */ /** * Run the full 5-axis probe against `store`. Returns a structured * report with per-axis measurements and a {@link SuitabilityScore}. * * The probe is **idempotent-per-run**: it picks unique record IDs per * invocation using a monotonically increasing counter seeded by * `Date.now()`, so concurrent probe runs against the same store do * not collide. */ declare function runStoreProbe(store: NoydbStore, options?: ProbeOptions): Promise; declare function probeTopology(options: TopologyProbeOptions): Promise; /** * **@noy-db/to-meter** — pass-through meter for `@noy-db/to-*` stores. * * Wraps any `NoydbStore` and returns a new store that behaves * identically but records per-method timing, error rates, byte * counts, and (optionally) periodic liveness status. The meter is * itself a `NoydbStore`, so it slots anywhere a store fits: * * ```ts * import { toMeter } from '@noy-db/to-meter' * import { awsDynamoStore } from '@noy-db/to-aws-dynamo' * * const dynamo = awsDynamoStore({ table: 'live' }) * const { store, meter } = toMeter(dynamo, { * liveness: { interval: 60_000 }, // optional synthetic pings * degradedMs: 200, // p99 threshold for `degraded` event * onDegraded: (e) => console.warn(e), * }) * * const db = await createNoydb({ store }) * * // at any time * console.log(meter.snapshot()) * // { * // byMethod: { * // get: { count: 142, p50: 3, p99: 28, errors: 0 }, * // put: { count: 43, p50: 11, p99: 92, errors: 1 }, * // ... * // }, * // status: 'ok' | 'degraded' | 'unreachable', * // casConflicts: 2, * // totalCalls: 230, * // windowMs: 45_280, * // } * ``` * * ## Relation to `withMetrics` * * This package **uses** hub's `withMetrics` middleware internally — * don't think of it as a replacement. `withMetrics` is the raw event * stream (one callback per op); `toMeter` is the aggregator that * bucketises events into percentiles + a health verdict. * * ## Two modes, one package (#845) * * - `runStoreProbe()` / `probeTopology()` run **synthetic** benchmarks on an * empty store — they answer "should I adopt this store?". Absorbed here from * the retired `@noy-db/to-probe`, which exported no store and so never fitted * the `to()` store-factory contract. * - `toMeter()` observes **real traffic** through the live store — it answers * "how is this store performing right now?". * * Composable: probe first to choose, then `toMeter(chosen)` to keep watching. * * @packageDocumentation */ type MethodName = 'get' | 'put' | 'delete' | 'list' | 'loadAll' | 'saveAll' | 'listPage' | 'getStoreTime' | 'tx' | 'listVaults' | 'ping'; type MeterStatus = 'ok' | 'degraded' | 'unreachable'; /** Latency + counts for a single store method. */ interface MethodStats { readonly count: number; readonly errors: number; readonly p50: number; readonly p90: number; readonly p99: number; readonly max: number; readonly avg: number; } /** Full snapshot of meter state at one moment. */ interface MeterSnapshot { readonly byMethod: Record; readonly status: MeterStatus; readonly casConflicts: number; readonly totalCalls: number; readonly windowMs: number; readonly collectedAt: string; } /** Degraded/restored event. */ interface MeterEvent { readonly type: 'degraded' | 'restored'; readonly status: MeterStatus; readonly method?: MethodName; readonly p99?: number; readonly reason: string; readonly at: string; } interface LivenessOptions { /** Milliseconds between synthetic health checks. */ readonly interval: number; /** Vault to use for the liveness `put`/`delete` pair. Default `'probe-vault'`. */ readonly vault?: string; /** Collection to use. Default `'probe-liveness'`. Do NOT use a `_`-prefixed name. */ readonly collection?: string; } interface MeterOptions { /** * Upper bound on retained latency samples per method. When the * sample array grows past this, oldest entries are dropped. Default * 1024 — keeps p50/p99 reasonably accurate with bounded memory. */ readonly sampleLimit?: number; /** * Optional periodic liveness ping. Uses the store's `ping()` if * available, otherwise falls back to a `put`/`delete` pair on a * dedicated collection. */ readonly liveness?: LivenessOptions; /** * p99 latency threshold (ms) for `put` — if crossed, emit a * `degraded` event. Default 500. */ readonly degradedMs?: number; /** Called when the meter transitions to `degraded`. */ readonly onDegraded?: (event: MeterEvent) => void; /** Called when the meter transitions back to `ok`. */ readonly onRestored?: (event: MeterEvent) => void; } /** Handle returned alongside the wrapped store. */ interface MeterHandle { /** Current snapshot. Safe to call frequently — O(k log k) on sample sizes. */ snapshot(): MeterSnapshot; /** Reset all counters and drop samples. Handy for per-request metering. */ reset(): void; /** Subscribe to degraded/restored transitions. Returns an unsubscribe fn. */ subscribe(listener: (event: MeterEvent) => void): () => void; /** Stop the liveness timer (if any) and release resources. */ close(): void; } /** * What {@link toMeter} returns: a fully-conformant {@link NoydbStore} that also * carries its own {@link MeterHandle}. * * Shaped after `RoutedNoydbStore` (hub's `routeStore`), which is likewise a * store plus a control surface. Being a store rather than a `{ store, meter }` * tuple is what lets a meter sit anywhere a store can — including nested inside * `routeStore`, so each backend in a compound topology can be metered * independently: * * ```ts * const pg = toMeter(toPostgres({ … })) * const s3 = toMeter(toAwsS3({ … })) * const db = await createNoydb({ store: routeStore({ default: pg, blobs: s3 }) }) * pg.meter.snapshot() // per-backend timings, no extra plumbing * ``` */ interface MeteredNoydbStore extends NoydbStore { readonly meter: MeterHandle; } /** * Wrap a store so every call is timed + counted. Returns the wrapped * store and a handle for inspecting the aggregate. * * The wrapped store is a drop-in replacement for the inner store — * same 6 methods, same types, same behaviour on success and error. The * meter adds zero semantic changes: errors still throw, conflicts * still surface as {@link ConflictError}. */ declare function toMeter(inner?: NoydbStore, options?: MeterOptions): MeteredNoydbStore; export { type CasAxis, type HydrationAxis, type LatencyStats, type LivenessOptions, type MeterEvent, type MeterHandle, type MeterOptions, type MeterSnapshot, type MeterStatus, type MeteredNoydbStore, type MethodName, type MethodStats, type NetworkAxis, type ProbeOptions, type ProbeRisk, type ProbeRiskCode, type ProbeRole, type StoreProbeReport, type SuitabilityScore, type SyncAxis, type TopologyProbeOptions, type TopologyProbeReport, type TopologyRisk, type TopologyTargetReport, type WriteAxis, probeTopology, runStoreProbe, toMeter };