npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@reopt-ai/data-contract

v0.15.0

Published

Wire contract (zod schemas + typed client) for the reopt-data ingest and query APIs

Readme

@reopt-ai/data-contract

The wire contract between reopt-data and its server-credential consumers: zod schemas for the ingest and query planes, plus a typed fetch client. One runtime dependency (zod), because consumers bundle this.

Defined by RFC-0023.

Subpaths

| Import | Contents | | -------------------------------------------- | ---------------------------------------------------------------------- | | @reopt-ai/data-contract | CONTRACT_VERSION, header names, DataApiError, shared primitives | | @reopt-ai/data-contract/ingest | POST /api/track request + response schemas | | @reopt-ai/data-contract/query | POST /api/v1/query/* request + response schemas | | @reopt-ai/data-contract/control | /api/v1/organizations, /projects, /projects/{id}/events | | @reopt-ai/data-contract/cli | browser-approved CLI auth and account response schemas | | @reopt-ai/data-contract/catalog | reopt-data.events.json — the event catalogue as a file | | @reopt-ai/data-contract/report | saved dashboard report specs (zReportSpec) | | @reopt-ai/data-contract/identity | cookie and header names shared by the SDKs, the proxy and ingest | | @reopt-ai/data-contract/events | automatic event names and property keys — no zod | | @reopt-ai/data-contract/segment | segment (audience) definition v2 — schema, catalogue, validation | | @reopt-ai/data-contract/definition | Project as Code — reopt-data/*.json file formats, defaults, upgrades | | @reopt-ai/data-contract/integration | Installation lifecycle, public browser connection and runtime schemas | | @reopt-ai/data-contract/integration-client | createIntegrationClient() — backend installation API | | @reopt-ai/data-contract/client | createDataClient() | | @reopt-ai/data-contract/ai | AI observability names, keys, limits, clamp, event id — no zod | | @reopt-ai/data-contract/ai-schema | zod schemas for /ai properties and project settings |

/client and /integration-client reference fetch. If you bring your own transport, import /query alone and you take on no fetch surface. /events has no zod either, so a browser bundle that only needs the event names carries no schema code.

/cli defines the user-facing device-login wire contract. CLI bearer sessions are a separate audience from browser cookie sessions and currently carry only the account:read scope; deployment provisioning keys remain separate.

Segments

A segment is a saved set of actors, not a saved WHERE fragment. That distinction is the whole reason /segment exists as its own subpath: the definition is read by the console, the API, the CLI and the query compiler, and four hand-synced copies of it is what the rebuild replaced.

import { validateSegmentDefinition, zSegmentDefinition } from "@reopt-ai/data-contract/segment";

const definition = zSegmentDefinition.parse(await response.json());
const issues = validateSegmentDefinition(definition);

The tree is two levels, fixed: SegmentDefinition { logic, groups[] }Group { logic, criteria[] }Criterion. Arbitrary depth costs a recursive builder, recursive validation and a parenthesising compiler, and buys an expressiveness nobody asks for.

Eleven criterion kinds, on a kind discriminator: performed, not_performed, first_time, regularly, stopped, restarted, sequence, property, segment (nested, depth 3), first_seen, static. Negation is a negate flag rather than a parallel set of not_* kinds, so a rule that has to reason about it reads one field instead of enumerating kinds.

CRITERIA_CATALOG is the single declaration of what a kind is — the fields it may store, what it defaults to, and how it reads as a sentence. A form layout, a save-time field whitelist and a summary chip that each carried their own copy would drift, and the drift would look like a data bug. cleanCriterion drops anything the catalogue does not declare, so a field the client invented never becomes persisted state.

validateSegmentDefinition(definition, ctx?) runs on both sides. A rule the client alone enforced would be bypassed by the API; a rule the server alone enforced would surface as a failed save instead of an inline hint. It returns every issue at once ({ path, code, message }) rather than throwing, because the builder shows them all. Nested-reference cycles are found through a ctx.resolveSegment callback — this package knows nothing about a database.

migrateSegmentDefinition(input) promotes v1 on read. It is an adapter, not a data migration: nothing is rewritten until the next save, so a rollback loses no segment. It is total by construction — unreadable input becomes an empty definition with a warning, never a throw, because it runs on the read path of a list endpoint where one malformed row must not take out the page.

describeCriterion(criterion, labels?) renders one line from the catalogue's sentence tokens, so the summary a list row shows and the row somebody edits cannot describe the same criterion differently. Korean copy for every code and token ships in SEGMENT_MESSAGES.ko; pass labels to override.

SEGMENT_API_PATHS covers the read-only HTTP surface (list, get, preview). Creating a segment means validating a definition against a project's catalogue and then computing it — expensive work that belongs behind a console somebody is looking at, not behind an unattended script.

Ingest modes

The credential decides the mode; there is no mode header to disagree with it.

| | browser (reopt-write-key) | server (reopt-client-id + reopt-client-secret) | | --------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | | 5-second click dedup | yes | no | | Idempotency | eventId | eventId — the only mechanism | | Device identity | reopt-device-id header, else generated per request | event deviceId, else the header, else profileId | | Sessions | per device | not created | | A row that fails validation | fails the whole batch (400) | reported in rejected[]; the batch proceeds |

Server mode exists because a server batch legitimately repeats the same event name many times per second, and its "device" is a process rather than a person. Applying browser rules to it silently discards events and fabricates sessions.

Reconciling a batch

Every 2xx satisfies:

accepted + duplicates + rejected.length === events sent
import { reconcileIngestResponse } from "@reopt-ai/data-contract/ingest";

const response = await client.ingest.track(batch);
if (!reconcileIngestResponse(response, batch.length)) {
  // The server dropped something without saying so — alert, do not advance.
}

Rejections are permanent: the same row rejects the same way on resend. Count them as skipped and move the cursor forward.

Reading

import { createDataClient } from "@reopt-ai/data-contract/client";

const client = createDataClient({
  baseUrl: process.env.REOPT_DATA_URL!,
  clientId: process.env.REOPT_DATA_CLIENT_ID!,
  clientSecret: process.env.REOPT_DATA_CLIENT_SECRET!,
});

const { data, meta } = await client.query.eventsTimeseries({
  projectId,
  startDate: "2026-08-01",
  endDate: "2026-08-23",
  granularity: "day",
  eventName: "checkout_completed",
  // Top-level event properties. Multiple filters are combined with AND.
  propertyFilters: [{ key: "surface", operator: "eq", value: "brandfront" }],
  breakdown: { kind: "property", key: "page_id", topN: 100 },
});

propertyFilters currently supports exact string equality only. Breakdown values are capped at 100 so page-scale tables stay bounded.

meta reports staleness as two separate numbers, never one sum, because they have different remedies:

  • cacheAgeSeconds — how old the returned computation is. Resolves itself at the next TTL.
  • ingestLagSeconds — how far behind live materialization was. 0 means no live event is pending; null plus ingestLagStatus: "unknown" means the measurement failed and must not be shown as fresh.
  • ingestDeadLetterCount — pending events older than the live replay window. These remain visible without pegging the live "aggregating" indicator.

Not now - max(events.created_at): that conflates a lagging pipeline with a quiet project and grows without bound while a project sits idle.

The event catalogue as a file

reopt-data.events.json declares what a project's events mean — display name, conversion flag, status, the properties the hourly rollups break them down by — next to the code that emits them. @reopt-ai/data-cli pushes it, so the catalogue and the deploy are the same commit.

{
  "$schema": "https://data.reopt.ai/schemas/event-catalog.v1.json",
  "version": 1,
  "projectId": "prj_123",
  "events": {
    "purchase": { "displayName": "Purchase", "conversion": true, "rollupProperties": ["plan"] },
    "healthcheck": { "status": "internal" },
    "$pageview": {},
  },
}

A field the file omits is the default, not "leave it alone." "$pageview": {} declares an active, non-conversion event with no rollup properties — not an event nobody has an opinion about. That is what lets verify answer whether the catalogue matches the file at all; under "omitted means untouched" there is no state the file could be said to describe. resolveEventCatalogEntry applies the defaults, and both the CLI and the server read them from there so a diff cannot invent drift on an event nobody touched.

Two fields are checked as a format on write, because nothing downstream can. icon is a Lucide icon name in PascalCase (^[A-Z][A-Za-z0-9]*$) — the key the console looks up, not the kebab-case spelling Lucide's docs use — and color is a six-digit hex (^#[0-9a-fA-F]{6}$), the same form the console's colour picker produces. Neither is checked against a list: an unknown-but-well-formed icon still renders (the console falls back), and an icon set moves faster than a released contract. What is refused is the value that was never plausible — a URL, a sentence, rgb(…), a three-digit shorthand whose swatch breaks when the UI appends its own alpha. Reads are not checked, so a value stored before this rule still exports.

Writes go through PUT /api/v1/projects/{id}/events under compare-and-set: each entry may carry the updatedAt the caller last saw, and one stale entry refuses the whole batch with conflict and a details.conflicts list. Partial application is what a caller cannot recover from — its baseline would then describe neither the file it pushed nor the catalogue it pushed to.

Properties are observed, not declared

The catalogue file (version: 1) carries events and no properties, and that is a decision rather than an omission.

An event is a name somebody chose and can be held to: declaring it in a file and failing a build when the code emits something else is a useful contract. A property is not. It appears the moment any client sends it, from a version of the app that shipped months ago and is still running, and the catalogue's job is to say what has actually been seen — which is why PropertyMeta is written by ingest and read at GET /api/v1/projects/{id}/properties. A declared property schema would make the file disagree with the data on every deploy, and the only honest response to that disagreement would be to ignore it.

What a person does own is meaning: a property's description, whether pickers offer it, and a correction to the type when the guess was wrong. Those three columns — and only those — are what a version: 2 file adds:

{
  "$schema": "https://data.reopt.ai/schemas/event-catalog.v2.json",
  "version": 2,
  "projectId": "prj_123",
  "events": { "purchase": { "conversion": true } },
  "properties": {
    "event": { "plan": { "propertyType": "string", "description": "Plan code" } },
    "profile": { "signup_source": { "hidden": true } },
  },
}

One rule is deliberately inverted against the event half. An event missing from the file is archived; a property missing from the file is left exactly as observed. Ingest is what makes a property exist, so retiring one here would delete a row the next event recreates — and the file would be claiming an authority it does not have. Inside a declared entry the usual rule still holds: {} asserts no description and not hidden, so a console description the file omits is cleared.

That asymmetry leaves a blind spot, and @reopt-ai/data-cli narrows it with two opt-in flags on event verify whose reports never overlap:

  • --strict-properties — a property somebody described or hid in the console and never wrote down (isCuratedProperty). A real decision living outside the file.
  • --strict-observed — a property ingest merely observed that the file never mentions. Excludes the SDK's own keys via isSdkOwnedProperty: a $ prefix is a reserved namespace, and an ordinary word like path or value counts as the SDK's only when every event that carried it is an automatic one. A value seen on a host's purchase is the host's.

--strict-observed cannot fail the commit that starts sending the key — the server has not observed it when that commit's CI runs — so it fails the next, unrelated run instead. That cost is why it is off by default and separate from the flag above, rather than a wider reading of it.

A declarative property schema — one that could reject an unexpected key at ingest rather than describe it afterwards — remains a different feature with a different failure mode, and is not what version: 2 does.

Project as Code

The event catalogue was the first setting to live in the repository. /definition extends the same treatment to the rest of a project's console settings — the files under reopt-data/ that @reopt-ai/data-cli moves with pull / diff / push / verify:

reopt-data/
  project.json        { "$schema", "version": 1, "projectId", "settings": { "timezone": "Asia/Seoul", "retentionDays": 90 } }
  error-rules.json    { "$schema", "version": 1, "projectId", "rules": [ { "key": "ignore-vendor", "name": "…", "action": { "kind": "suppression" }, "filters": [ … ] } ] }
reopt-data.lock.json  the CLI's record of what the server held at the last sync

Three rules carry over unchanged. The file is the truth: a setting the file omits is the default, and the defaults live in resolveProjectSettings / resolveErrorRuleEntry here and nowhere else, so the console, the server and a push cannot disagree about a setting nobody touched. Writes are compare-and-set: PATCH /api/v1/projects/{id} takes expectedUpdatedAt, and PUT /api/v1/projects/{id}/error-rules takes one per rule; a stale value refuses the whole request with conflict. Every file names its version, and upgradeDefinition walks an older document to the current shape before it is parsed — the CLI always writes the latest.

One rule is new, and it is what makes the console and the repository able to share a project. Ownership is a key. A rule with a key belongs to the repository: the file declares it, a push edits it, dropping it from the file deletes it. A rule without a key belongs to the console and no push ever touches it — it is listed, counted, and left alone. PATCH …/error-rules/{ruleId} with { "key": "…" } hands a console rule to the repository (reopt-data import), and { "key": null } hands it back (reopt-data unlink). A keyed rule the push did not account for — somebody else imported it since the caller last pulled — is refused as conflict with details.unexpected, because deleting it silently is exactly the surprise the key exists to prevent.

Order is the file's array order. After a push, keyed rules are 0..n-1 in that order and console-owned rules follow in their existing relative order, so the evaluator's first-match tiebreak reads the same way the file does.

Risk policies live in definition: exhaustive project-field, error-rule-kind and alert-field registries are shared with the CLI, and unknown changes are destructive. Retention decreases, setting an inherited limit, and clearing an override require explicit approval. Direct PATCH and atomic definition PUT enforce allowDestructive on the server; the CLI additionally requires --yes.

Definition PUT accepts a UUID operationId. Identical retries return the original transaction snapshot before CAS; different input reusing that ID returns 409 idempotency_conflict. GET .../definition/operations/:operationId recovers it. Receipts are org-scoped, contain sensitive destinations and remain until project deletion. Control errors expose classification, retryable and resolution (action/message), which DataApiError and the CLI preserve.

client.control.applyProjectDefinition(projectId, input, auth) sends one PUT /api/v1/projects/{id}/definition. Optional project, errorRules, and alerts groups commit together; any CAS or validation failure rolls back all of them. dryRun is controlled at the top level. The response contains { applied, snapshot: { project, errorRules, alerts } }, captured inside that transaction. The CLI uses this snapshot as its lock baseline. Console CRUD, import/unlink and REST writes share the parent-project lock protocol.

Nullable settings text is trimmed and blank text becomes null in the shared resolver. For issue alerts, omitted unhandledOnly and false resolve equally. Delivery bookkeeping changes lastTriggeredAt without changing the definition's updatedAt.

Alerts, secrets and environments

reopt-data/alerts.json follows the same ownership rule (key, PUT /api/v1/projects/{id}/alerts, PATCH …/alerts/{alertId}), with two differences the file format carries.

A webhook URL is a secret and this file is committed, so destination may be a ${env:NAME} reference — an upper-case environment variable name, as CI secret stores spell them. resolveEnvReferences substitutes it and collects every name the environment lacks, so a missing secret is reported once with all its siblings rather than one CI run at a time. The server never sees a reference: the CLI resolves before it sends, and alertDestinationIssues rejects unresolved references and checks the resolved value by channel (an address for email, an https URL for slack and webhook).

projectId is optional in every file. A repository that maps staging and production to two projects keeps one set of files and names the project in reopt-data.config (environments); the CLI keeps one lock per environment (projectDefinitionLockFilename("staging")reopt-data.staging.lock.json). A file that does name a project is pinned to it, and a mismatch with the environment's project is an error, not a guess.

zProject.definitionPushedAt / definitionPushedBy record the last push — the whole-list writes and a PATCH that asserted expectedUpdatedAt; a host binding a symbol app blind is not a push.

Errors

Non-2xx responses throw DataApiError with a stable code. Branch on code, never on error/message — those are prose.

Note that the two 429s mean different things: rate_limited clears on its own, quota_exceeded needs a human to raise the limit.

A 2xx whose body does not match the contract also throws, as contract_mismatch, rather than being handed back half-understood.

Service integration

See the shared integration contract for authority boundaries, PKCE, binding CAS, public projections, managed credit allowances and rollout order. Package 0.11.0 retains analytics wire version 0.10.0; installation protocol version 1 has its own header.

Replay wire contract

The @reopt-ai/data-contract/replay subpath defines version 1 start/chunk schemas, event and byte limits, project settings, upload grants, privacy filtering, and gap-aware chunk metadata. Start accepts only a random document streamId; callers cannot supply project, device, profile or analytics session identity in the body. Chunk metadata is derived by the receiver. Replay payloads are never analytics event properties. This subpath is available in the workspace pending the next package release.

Replay public assets use ReplayPublicAsset, REPLAY_ASSET_LIMITS, and createReplayAssetResolver from /replay. The client sanitizer removes all resources unless an explicit resolver maps a reviewed static file. Server ingestion uses embeddedReplayAsset to revalidate only bounded PNG/JPEG/WebP/WOFF2 data URLs; external URLs remain blocked. These are additive v1 DOM payload capabilities. Pixel content and font metadata are not anonymized; consumer setup and exclusions are documented in the client README.

AI observability

@reopt-ai/data-contract/ai fixes the wire for LLM calls (RFC). Five events — $ai_generation (one model call), $ai_embedding, $ai_span (tool execution or custom section), $ai_trace (one top-level operation, sent once at its end) and $ai_feedback — carry $ai_* snake_case keys from AI_PROPERTIES. Every event but feedback needs $ai_trace_id and $ai_span_id; feedback targets a trace (optionally a span). Hosts' own properties ride along untouched.

Three rules are shared rather than left to each side:

  • One clamp. clampAiProperties() is what the SDK runs before sending and what ingest runs on receipt, so both cut at the same place (AI_LIMITS). It is lenient: a mistyped $ai_* key is dropped instead of failing the event, counts are floored to non-negative integers, content is JSON-serialized, truncated without splitting a surrogate pair, and flagged with $ai_content_truncated.
  • Server-owned cost. $ai_cost_usd and $ai_cost_source (AI_SERVER_OWNED_PROPERTIES) are removed from client input and recomputed by ingest. A client passes what it was told — for example by AI Gateway — as $ai_reported_cost_usd.
  • Deterministic ids. aiEventId(traceId, spanId, kind) is a UUIDv5 computed with a bundled SHA-1, so node, edge and browsers produce the same id. Server-mode ingest dedups on event id, so a hook that fires twice or a retried batch lands once.

Prompt and response content ($ai_input/$ai_output) is off by default: it is stored only when both the SDK option and the project's captureContent setting are on. /ai has no zod so the SDK's telemetry integration stays schema-free; /ai-schema adds lenient zod schemas (unknown keys kept) and re-exports /ai. Analytics wire version is unchanged — the additions are additive.