diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 00000000..d2e7deb1 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "buzzkit-dev/buzzkit" }], + "commit": false, + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "@buzzkit/api", + "@buzzkit/web", + "@buzzkit/marketing", + "@buzzkit/docs", + "@buzzkit/schema", + "@buzzkit/database", + "@buzzkit/auth", + "@buzzkit/eden", + "@buzzkit/observability", + "@buzzkit/tinybird", + "@buzzkit/ui" + ] +} diff --git a/.changeset/tender-pugs-invite.md b/.changeset/tender-pugs-invite.md new file mode 100644 index 00000000..44b8c3ad --- /dev/null +++ b/.changeset/tender-pugs-invite.md @@ -0,0 +1,5 @@ +--- +'buzzkit': major +--- + +The first public release. One package with seven entry points: the server client, the browser client and its React hooks, webhook signing and verification, and the segment, workflow and source grammars. Entity types are held to the API by type-level parity assertions, every list pages the same way, and retries cover connection failures, timeouts, 429 and 5xx with jitter and Retry-After. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..532d0462 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + branches: [main] + paths: + - 'packages/buzzkit/**' + - '.changeset/**' + - '.github/workflows/release.yml' + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release: + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - name: npm new enough to publish through the GitHub trusted publisher + run: npm install --global npm@12.0.2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - run: bun install --frozen-lockfile + - name: Build and test the SDK + run: | + bun run sdk:build + bun run --cwd packages/buzzkit test + bun run --cwd packages/buzzkit exec publint + - name: Prove the published package imports + run: | + cd packages/buzzkit + bun run scripts/publish-manifest.ts + bun pm pack --destination /tmp/pack + cd /tmp/pack + mkdir consumer && cd consumer + printf '{"name":"c","type":"module","private":true}' > package.json + npm install ../buzzkit-*.tgz + node --input-type=module -e " + import { BuzzKit, signIdentity } from 'buzzkit'; + import { BuzzKitClient } from 'buzzkit/client'; + import { verifyWebhook } from 'buzzkit/webhooks'; + if (typeof BuzzKit !== 'function') throw new Error('the root entry did not load'); + if (typeof BuzzKitClient !== 'function') throw new Error('the browser entry did not load'); + if (typeof signIdentity !== 'function' || typeof verifyWebhook !== 'function') { + throw new Error('a server helper did not load'); + } + " + cd "$GITHUB_WORKSPACE" && git checkout -- packages/buzzkit/package.json + - uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + version: bunx changeset version + publish: bun run sdk:release + title: 'chore: release the SDK' + commit: 'chore: release the SDK' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 15f3f7cf..1154c891 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ buzzkit — open-source, self-hostable, code-first push notification framework. Two layers, one codebase: -1. **The framework (headless core)** — multi-tenant push infrastructure defined entirely in code: workspaces with isolated APNs/FCM credentials, device token lifecycle, sending, scheduled sends, segments and workflows (bring your own provider credentials, buzzkit is the framework around them). Segments and workflows are defined in the dashboard or through the API; the `buzzkit` package is the server SDK (send, subscribers, events, inline segment expressions on a send), never a place to define and deploy workflows from code. +1. **The framework (headless core)** — multi-tenant push infrastructure defined entirely in code: workspaces with isolated APNs/FCM credentials, device token lifecycle, sending, scheduled sends, segments and workflows (bring your own provider credentials, buzzkit is the framework around them). Segments and workflows are defined in the dashboard or through the API; the `buzzkit` package is the SDK customers install, and it types the workflow grammar without being a place to define and deploy workflows from code. 2. **The platform (`apps/web` + hosted version)** — a full product built *on top of* the framework. The hosted version is just a deployment of the same multi-tenant core; it must never need anything the framework doesn't expose. **Multi-tenancy is the core primitive, not a feature.** Every design decision must work for both a single self-hoster and the hosted platform running thousands of workspaces. @@ -25,7 +25,7 @@ apps/ping/ → @buzzkit/ping The Buzz API at ping.buzzki agents POST to for notifications, Live Activity progress and blocking approvals. A customer of the framework that lives in the repo — it may import `buzzkit` and `@buzzkit/observability` and nothing else (`apps/ping/CLAUDE.md`) -packages/buzzkit/ → buzzkit The public server SDK (send, subscribers, events, webhooks, segment expressions) +packages/buzzkit/ → buzzkit The public SDK: server, browser and React entries, plus the wire vocabularies and grammars every other package derives from packages/schema/ → @buzzkit/schema Grammars the API and the dashboard both validate (`/workflows`: types, lint, parsers; `/sources`: webhook mappings and presets; `/imports`: CSV parsing, provider presets and row mapping for bulk imports), private packages/database/ → @buzzkit/database Drizzle ORM, PostgreSQL schema, migrations @@ -36,7 +36,7 @@ packages/tinybird/ → @buzzkit/tinybird The event log: Tinybird dat packages/ui/ → @buzzkit/ui Design system: shadcn (Base UI style) + Tailwind v4 tokens, Central Icons ``` -**`buzzkit` is one package, and it is the server SDK.** Only what a customer's backend uses lives in `packages/buzzkit`: the send client, subscriber and event APIs, webhook verification and the segment expression grammar (types + lint, so an inline segment on a send is typed and checked before it is sent), organized by subpath exports (`buzzkit/webhooks`, `buzzkit/expressions`, …) — never split into separate npm packages for organization's sake, and never holding anything that only runs on the server (request schemas, evaluators, renderers). Workflows are not defined from code: their language is the private `@buzzkit/schema/workflows` package, their runtime is the API. The platform (`apps/api`) depends on `buzzkit` directly; that's the dogfooding constraint made concrete. (The root workspace is named `buzzkit-monorepo` so the package can own the bare `buzzkit` name.) +**`buzzkit` is one package with seven entry points**, and it is the source of truth for the contract. A definition lives here exactly when a customer can observe it through the public API: the server client (`buzzkit`), the browser client (`buzzkit/client`) and its hooks (`buzzkit/react`), webhook verification, and the expression, workflow and source grammars — never split into separate npm packages for organization's sake, and never holding anything that only runs on our side (request schemas, evaluators, renderers). `@buzzkit/schema` keeps the behavior over those grammars and `packages/database` derives its enums from the same arrays, so the dependency runs one way. Workflows are still not defined from code: their runtime is the API. The platform (`apps/api`) depends on `buzzkit` directly; that's the dogfooding constraint made concrete. (The root workspace is named `buzzkit-monorepo` so the package can own the bare `buzzkit` name.) The API dev server runs on port **8790**, the web dev server on port **5180** (offset from feedbase's 8788/5173 so both repos can run side by side). `bun db:up` starts Postgres (5460) and Tinybird Local (7181); after a fresh Tinybird container, `bun run push` in `packages/tinybird` pushes the event tables and endpoints into it (it is `push`, not `build`, so turbo's `build`/`test` graphs never depend on a running Tinybird). @@ -47,7 +47,7 @@ The API dev server runs on port **8790**, the web dev server on port **5180** (o - **Database**: PostgreSQL via Drizzle ORM (what *is*); Tinybird for the event stream (what *happened*); a Durable Object per subscriber for what is true right now (`docs/engine.md`) - **Dashboard**: deliberately **not Next.js** — Vite + React Router 8 SSR via `@cloudflare/vite-plugin` - **Package Manager**: Bun with workspaces -- **Code Quality**: Biome (pinned exactly; hardened rule set + custom GritQL plugins in `.biome/plugins/`: no awaited calls in ternaries, no interpolated span names), `scripts/lint-conventions.ts` (the comments ban + the function-verb catalog), knip (dead exports/files/deps), Sherif, publint on `packages/buzzkit`. Husky hooks: pre-commit (lint-staged Biome on staged files + conventions + sherif), commit-msg (conventional commits), pre-push (check-types + unit tests). CI (Blacksmith runners): `.github/workflows/lint.yml` (Biome + conventions + sherif + knip, check-types), `.github/workflows/test.yml` (unit suites; full API integration suite against Postgres + Tinybird via docker compose) `.github/workflows/agentic-audit.yml` (daily Is Agentic scan of production, ratcheted against `.github/agentic-baseline.json`: it fails when the score drops below 80 or below the last score, or when fewer essential checks pass than last time, and raises the baseline by itself when the score improves) and `.github/workflows/device-suite.yml` (on `main`, after Cloudflare Workers Builds has deployed the API for that commit, dispatch the device suite in `buzzkit-ios` against production and wait for it, so the commit is green only when the real product is; rolling back is a person's call in the Cloudflare dashboard). Deploys themselves are Cloudflare Workers Builds, connected to this repository, not Actions. The `conventions` skill (`.claude/skills/conventions`) is the pattern catalog — load it before writing code. +- **Code Quality**: Biome (pinned exactly; hardened rule set + custom GritQL plugins in `.biome/plugins/`: no awaited calls in ternaries, no interpolated span names), `scripts/lint-conventions.ts` (the comments ban + the function-verb catalog), knip (dead exports/files/deps), Sherif, publint on `packages/buzzkit`. Husky hooks: pre-commit (lint-staged Biome on staged files + conventions + sherif), commit-msg (conventional commits), pre-push (check-types + unit tests). CI (Blacksmith runners): `.github/workflows/lint.yml` (Biome + conventions + sherif + knip, check-types), `.github/workflows/test.yml` (unit suites; full API integration suite against Postgres + Tinybird via docker compose) `.github/workflows/agentic-audit.yml` (daily Is Agentic scan of production, ratcheted against `.github/agentic-baseline.json`: it fails when the score drops below 80 or below the last score, or when fewer essential checks pass than last time, and raises the baseline by itself when the score improves) and `.github/workflows/device-suite.yml` (on `main`, after Cloudflare Workers Builds has deployed the API for that commit, dispatch the device suite in `buzzkit-ios` against production and wait for it, so the commit is green only when the real product is; rolling back is a person's call in the Cloudflare dashboard). Deploys themselves are Cloudflare Workers Builds, connected to this repository, not Actions. `packages/buzzkit` is the one published package: `.github/workflows/release.yml` versions and publishes it from changesets, and a customer-visible change to it needs `bun run changeset` in the same commit (`packages/buzzkit/CLAUDE.md` → Releasing). The `conventions` skill (`.claude/skills/conventions`) is the pattern catalog — load it before writing code. ## Commands diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md index bc341875..0e0b5b97 100644 --- a/apps/api/CLAUDE.md +++ b/apps/api/CLAUDE.md @@ -84,7 +84,7 @@ src/ ## Rules (non-negotiable) - **Routes:** `modules/` mirrors the API path, one folder per segment, dynamic segments in brackets named exactly like the param (`[workspaceSlug]`, `[tenantSlug]`, `[externalId]`, `[id]`), every route file is `index.ts`. Handlers are declared in verb order: `get`, `post`, `put`, `patch`, `delete`. Flat registration in `modules/v1/index.ts` — route modules never `.use()` each other. Collection modules export the plural, `[id]` modules the singular. -- **Thin handlers:** domain logic lives in `src/api//index.ts` as plain functions taking `Db`; handlers authorize → call domain functions → `Response`. A route file contains nothing but its Elysia instance — no local helper functions, types, or serializers; anything reusable goes to `src/api/` (domain) or `src/libs` (infrastructure). +- **Thin handlers:** domain logic lives in `src/api//index.ts` as plain functions taking `Db`; handlers authorize → call domain functions → `Response`. A route file contains nothing but its Elysia instance — no local helper functions, types, serializers **or schemas**; anything reusable goes to `src/api/` (domain) or `src/libs` (infrastructure). Every `body:` and `query:` is a named schema in the resource's `schemas.ts`, never an inline `t.Object` in the route: an inline one cannot be asserted against the SDK's param type, which is how request drift is caught (`test/packages/buzzkit/parity.ts`). - **Imports:** package self-references (`@buzzkit/api/libs/error`), never path aliases — keeps the contract type-consumable by the SDK. - **Responses:** always `Response.success()` / `Response.list()` / `Response.page()` (every list is `{ items, hasMore, nextCursor, total? }`) / `Response.error()` (envelope + Sqids transform); `markDeleted()` on every DELETE. Contract: `docs/api/conventions.md`. Root `id` needs `{ entity: '…' }`; new `*Id` field names need a `FIELD_ENTITIES` entry in `libs/response.ts`. An empty PATCH returns 200 with the unchanged entity, never 400. - **Pagination is domain-owned** (model: `listAuditEvents`): the `list*` function takes `{ cursor?, limit?, …filters }`, uses `clampLimit` + `resolveCursor` + a `limit + 1` fetch, returns `toPage`/`toPageBy` (+ `total` where served); the route is authorize → domain call → `Response.page(page)`. Never assemble `hasMore`/`nextCursor` in a route file. diff --git a/apps/api/src/api/credentials/index.ts b/apps/api/src/api/credentials/index.ts index 1c8d7555..3b66171f 100644 --- a/apps/api/src/api/credentials/index.ts +++ b/apps/api/src/api/credentials/index.ts @@ -95,7 +95,7 @@ export async function replaceCredential( provider: CredentialProvider; environment: CredentialEnvironment; secret: string; - details: Record; + details: Record; outcome: ValidationOutcome; } ): Promise { diff --git a/apps/api/src/api/credentials/validation.ts b/apps/api/src/api/credentials/validation.ts index def8f689..fe619cf1 100644 --- a/apps/api/src/api/credentials/validation.ts +++ b/apps/api/src/api/credentials/validation.ts @@ -149,7 +149,7 @@ export async function revalidateCredential(db: Db, credential: Credential): Prom return updated!; } - const details = credential.details as Record; + const { details } = credential; let outcome: ValidationOutcome; try { outcome = await validateCredentialUpload(credential.provider, { diff --git a/apps/api/src/api/deliveries/index.ts b/apps/api/src/api/deliveries/index.ts index deb6bb16..49f140bc 100644 --- a/apps/api/src/api/deliveries/index.ts +++ b/apps/api/src/api/deliveries/index.ts @@ -11,6 +11,7 @@ export * from './attempts'; export * from './constants'; export type { CounterDelta } from './policy'; export * from './receipts'; +export * from './schemas'; export * from './serialize'; export type * from './types'; diff --git a/apps/api/src/api/deliveries/schemas.ts b/apps/api/src/api/deliveries/schemas.ts new file mode 100644 index 00000000..e2ea5a5d --- /dev/null +++ b/apps/api/src/api/deliveries/schemas.ts @@ -0,0 +1,9 @@ +import { literalUnion } from '@buzzkit/api/libs/schemas'; +import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; +import { t } from 'elysia'; +import { DELIVERY_STATUSES } from './constants'; + +export const ListMessageDeliveriesQuerySchema = t.Object({ + ...PaginationQuerySchema.properties, + status: t.Optional(literalUnion(DELIVERY_STATUSES)), +}); diff --git a/apps/api/src/api/events/constants.ts b/apps/api/src/api/events/constants.ts index b9d02d52..f06be750 100644 --- a/apps/api/src/api/events/constants.ts +++ b/apps/api/src/api/events/constants.ts @@ -2,7 +2,7 @@ import { DAY_MS } from '@buzzkit/api/libs/timezone'; import type { EventVolumeRange } from './types'; -export const EVENT_SOURCES = ['server', 'ios', 'android', 'web', 'system'] as const; +export { EVENT_SOURCES } from 'buzzkit'; export const CLIENT_SOURCES = ['ios', 'android', 'web'] as const; diff --git a/apps/api/src/api/events/schemas.ts b/apps/api/src/api/events/schemas.ts index f47ae092..d47083e8 100644 --- a/apps/api/src/api/events/schemas.ts +++ b/apps/api/src/api/events/schemas.ts @@ -1,5 +1,7 @@ import { BadRequestError } from '@buzzkit/api/libs/error'; import { IdentityHashSchema, literalUnion } from '@buzzkit/api/libs/schemas'; +import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; +import { EVENT_FILTER_SOURCES, EVENT_VOLUME_RANGES } from 'buzzkit'; import { t } from 'elysia'; import { CLIENT_SOURCES, EVENT_SOURCES, MAX_EVENTS_PER_REQUEST } from './constants'; @@ -15,7 +17,7 @@ export const EventSourceSchema = literalUnion(EVENT_SOURCES); export const ClientSourceSchema = literalUnion(CLIENT_SOURCES); -export const EventVolumeRangeSchema = t.Union([t.Literal('24h'), t.Literal('7d'), t.Literal('30d')]); +export const EventVolumeRangeSchema = literalUnion(EVENT_VOLUME_RANGES); export const TrackEventSchema = t.Object({ id: t.Optional(EventIdSchema), @@ -67,3 +69,12 @@ export function assertEventDataObjects(body: unknown): void { } }); } + +export const ListEventsQuerySchema = t.Object({ + ...PaginationQuerySchema.properties, + name: t.Optional(EventNameSchema), + source: t.Optional(literalUnion(EVENT_FILTER_SOURCES)), + provider: t.Optional(t.String()), + after: t.Optional(t.String({ format: 'date-time' })), + afterId: t.Optional(EventIdSchema), +}); diff --git a/apps/api/src/api/events/track.ts b/apps/api/src/api/events/track.ts index a2864559..573fce76 100644 --- a/apps/api/src/api/events/track.ts +++ b/apps/api/src/api/events/track.ts @@ -157,7 +157,7 @@ async function promoteReceipts( } export function subscriberAttributes(subscriber: Pick): Record { - return (subscriber.attributes ?? {}) as Record; + return subscriber.attributes; } function resolveTrackedEvent(event: EventInput, source: EventSource, now: Date): ActorEventInput { diff --git a/apps/api/src/api/events/types.ts b/apps/api/src/api/events/types.ts index daa5d854..c6d17a8d 100644 --- a/apps/api/src/api/events/types.ts +++ b/apps/api/src/api/events/types.ts @@ -1,8 +1,9 @@ +import type { EVENT_FILTER_SOURCES } from 'buzzkit'; import type { Static } from 'elysia'; import type { SDK_EVENTS, SYSTEM_EVENTS } from './catalog'; -import type { CLIENT_SOURCES, EVENT_SOURCES } from './constants'; +import type { CLIENT_SOURCES } from './constants'; -export type EventSource = (typeof EVENT_SOURCES)[number] | 'webhook'; +export type EventSource = (typeof EVENT_FILTER_SOURCES)[number]; export type ClientSource = (typeof CLIENT_SOURCES)[number]; diff --git a/apps/api/src/api/messages/schemas.ts b/apps/api/src/api/messages/schemas.ts index 2af183b2..6db26c61 100644 --- a/apps/api/src/api/messages/schemas.ts +++ b/apps/api/src/api/messages/schemas.ts @@ -3,6 +3,7 @@ import { SegmentExpressionSchema } from '@buzzkit/api/api/segments/index'; import { ExternalIdSchema } from '@buzzkit/api/api/subscribers/index'; import { TopicSlugSchema } from '@buzzkit/api/api/topics/index'; import { ChannelSchema, SlugSchema, UrlSchema } from '@buzzkit/api/libs/schemas'; +import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; import { t } from 'elysia'; import { MAX_DIRECT_TARGETS, MAX_TTL_SECONDS, MESSAGE_STATUSES } from './constants'; @@ -87,3 +88,8 @@ export const MessageFiltersSchema = t.Object({ from: t.Optional(t.String({ format: 'date-time' })), to: t.Optional(t.String({ format: 'date-time' })), }); + +export const ListMessagesQuerySchema = t.Object({ + ...PaginationQuerySchema.properties, + ...MessageFiltersSchema.properties, +}); diff --git a/apps/api/src/api/messages/send.ts b/apps/api/src/api/messages/send.ts index 446d441b..c36dbc48 100644 --- a/apps/api/src/api/messages/send.ts +++ b/apps/api/src/api/messages/send.ts @@ -77,7 +77,7 @@ async function findCredentialForProvider( id: credential.id, updatedAt: credential.updatedAt, environment: credential.environment, - details: credential.details as Record, + details: credential.details, secret: await decryptCredentialSecret(credential), }; } diff --git a/apps/api/src/api/runs/constants.ts b/apps/api/src/api/runs/constants.ts index de4184a5..406188a7 100644 --- a/apps/api/src/api/runs/constants.ts +++ b/apps/api/src/api/runs/constants.ts @@ -1,4 +1,4 @@ -export const RUN_STATUSES = ['running', 'sleeping', 'waiting', 'completed', 'canceled', 'failed'] as const; +export { RUN_STATUSES } from 'buzzkit'; export const RUN_EVENTS_LIMIT = 1000; diff --git a/apps/api/src/api/runs/index.ts b/apps/api/src/api/runs/index.ts index 069f194a..add49d17 100644 --- a/apps/api/src/api/runs/index.ts +++ b/apps/api/src/api/runs/index.ts @@ -20,6 +20,7 @@ import type { } from './types'; export * from './constants'; +export * from './schemas'; export * from './serialize'; export * from './types'; diff --git a/apps/api/src/api/runs/schemas.ts b/apps/api/src/api/runs/schemas.ts new file mode 100644 index 00000000..2f1c21ca --- /dev/null +++ b/apps/api/src/api/runs/schemas.ts @@ -0,0 +1,15 @@ +import { literalUnion, SlugSchema } from '@buzzkit/api/libs/schemas'; +import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; +import { t } from 'elysia'; +import { RUN_STATUSES } from './constants'; + +export const ListRunsQuerySchema = t.Object({ + ...PaginationQuerySchema.properties, + status: t.Optional(literalUnion(RUN_STATUSES)), + workflow: t.Optional(SlugSchema), +}); + +export const ListWorkflowRunsQuerySchema = t.Object({ + ...PaginationQuerySchema.properties, + status: t.Optional(literalUnion(RUN_STATUSES)), +}); diff --git a/apps/api/src/api/segments/expression-schema.ts b/apps/api/src/api/segments/expression-schema.ts index 4c23052a..3aecf007 100644 --- a/apps/api/src/api/segments/expression-schema.ts +++ b/apps/api/src/api/segments/expression-schema.ts @@ -1,6 +1,6 @@ import { type Static, type TSchema, Type } from '@sinclair/typebox'; +import { CHANNELS } from 'buzzkit'; import { - CHANNELS, DURATION_PATTERN, EVENT_NAME_PATTERN, MAX_EXPRESSION_DEPTH, diff --git a/apps/api/src/api/segments/index.ts b/apps/api/src/api/segments/index.ts index 79f1000c..37a550b7 100644 --- a/apps/api/src/api/segments/index.ts +++ b/apps/api/src/api/segments/index.ts @@ -138,7 +138,11 @@ export async function updateSegment( insertVersion: async (tx, nextVersion) => { const [created] = await tx .insert(tables.segmentVersion) - .values({ segmentId: existing.id, version: nextVersion, expression: input.expression }) + .values({ + segmentId: existing.id, + version: nextVersion, + expression: input.expression ?? existing.version.expression, + }) .returning(); return created!; }, diff --git a/apps/api/src/api/sources/deliveries.ts b/apps/api/src/api/sources/deliveries.ts index 07d7ea74..d4411031 100644 --- a/apps/api/src/api/sources/deliveries.ts +++ b/apps/api/src/api/sources/deliveries.ts @@ -3,7 +3,7 @@ import { decodeEntityId, encodeId } from '@buzzkit/api/libs/sqids'; import { DAY_MS } from '@buzzkit/api/libs/timezone'; import { clampLimit, type Page, resolveCursor, toPage } from '@buzzkit/api/utils/pagination'; import { and, type Db, desc, eq, lt, tables } from '@buzzkit/database'; -import { DELIVERY_OUTCOMES, type DeliveryOutcome } from '@buzzkit/schema/sources'; +import { type DeliveryOutcome, SOURCE_DELIVERY_OUTCOMES } from '@buzzkit/schema/sources'; import { DELIVERY_RETENTION_DAYS } from './constants'; import { serializeSourceDelivery } from './serialize'; @@ -29,7 +29,7 @@ export async function listSourceDeliveries( ): Promise> & { total: number }> { const limit = clampLimit(options.limit); const beforeId = resolveCursor(options.cursor, (id) => decodeEntityId('sourceDelivery', id)); - const outcome = (DELIVERY_OUTCOMES as readonly string[]).includes(options.outcome ?? '') + const outcome = (SOURCE_DELIVERY_OUTCOMES as readonly string[]).includes(options.outcome ?? '') ? (options.outcome as DeliveryOutcome) : undefined; diff --git a/apps/api/src/api/sources/schemas.ts b/apps/api/src/api/sources/schemas.ts index 2f4959f6..d53c40ae 100644 --- a/apps/api/src/api/sources/schemas.ts +++ b/apps/api/src/api/sources/schemas.ts @@ -1,4 +1,6 @@ import { BadRequestError } from '@buzzkit/api/libs/error'; +import { literalUnion } from '@buzzkit/api/libs/schemas'; +import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; import { lintSourceMapping, lintVerification, @@ -6,6 +8,7 @@ import { type SourceMapping, type Verification, } from '@buzzkit/schema/sources'; +import { SOURCE_DELIVERY_OUTCOMES } from 'buzzkit'; import { t } from 'elysia'; export const SourceProviderSchema = t.String({ minLength: 1, maxLength: 40 }); @@ -54,3 +57,8 @@ export function assertVerification(value: unknown): asserts value is Verificatio }); } } + +export const ListSourceDeliveriesQuerySchema = t.Object({ + ...PaginationQuerySchema.properties, + outcome: t.Optional(literalUnion(SOURCE_DELIVERY_OUTCOMES)), +}); diff --git a/apps/api/src/api/sources/serialize.ts b/apps/api/src/api/sources/serialize.ts index d36a2746..41171c60 100644 --- a/apps/api/src/api/sources/serialize.ts +++ b/apps/api/src/api/sources/serialize.ts @@ -1,4 +1,4 @@ -import type { SourceMapping, SourceProvider, SourceStatus, Verification } from '@buzzkit/schema/sources'; +import type { SourceMapping, SourceStatus, Verification } from '@buzzkit/schema/sources'; import type { Source, SourceDelivery } from './types'; function ingestUrl(sourceId: string): string { @@ -9,7 +9,7 @@ export function serializeSource(source: Source, id: string) { return { id: source.id, name: source.name, - provider: source.provider as SourceProvider, + provider: source.provider, status: source.status as SourceStatus, url: ingestUrl(id), mapping: source.mapping as SourceMapping, diff --git a/apps/api/src/api/stats/constants.ts b/apps/api/src/api/stats/constants.ts index c1be72e2..292d342f 100644 --- a/apps/api/src/api/stats/constants.ts +++ b/apps/api/src/api/stats/constants.ts @@ -1,4 +1,4 @@ -export const STATS_INTERVALS = ['hour', 'day', 'week', 'month'] as const; +export { STATS_INTERVALS } from 'buzzkit'; export const MAX_RANGE_DAYS = 366; diff --git a/apps/api/src/api/subscribers/schemas.ts b/apps/api/src/api/subscribers/schemas.ts index 5f91c448..fe75fb08 100644 --- a/apps/api/src/api/subscribers/schemas.ts +++ b/apps/api/src/api/subscribers/schemas.ts @@ -5,6 +5,7 @@ import { IdentityHashSchema, PlatformSchema, } from '@buzzkit/api/libs/schemas'; +import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; import { t } from 'elysia'; export const ExternalIdSchema = t.String({ minLength: 1, maxLength: 256 }); @@ -56,3 +57,8 @@ export const SubscriptionInputSchema = t.Object({ export type DeviceContext = typeof DeviceContextSchema.static; export type SubscriptionInput = typeof SubscriptionInputSchema.static; + +export const ListSubscribersQuerySchema = t.Object({ + ...PaginationQuerySchema.properties, + search: t.Optional(t.String({ minLength: 1, maxLength: 200 })), +}); diff --git a/apps/api/src/api/tenants/settings.ts b/apps/api/src/api/tenants/settings.ts index 938d3b30..a93336f0 100644 --- a/apps/api/src/api/tenants/settings.ts +++ b/apps/api/src/api/tenants/settings.ts @@ -124,7 +124,7 @@ export function resolveTenantSettings(raw: unknown): TenantSettings { }; } -export function mergeTenantSettings(current: unknown, patch: TenantSettingsPatch): unknown { +export function mergeTenantSettings(current: unknown, patch: TenantSettingsPatch): Record { const stored = (current ?? {}) as { identity?: TenantSettingsPatch['identity']; channels?: TenantSettingsPatch['channels']; diff --git a/apps/api/src/api/webhooks/schemas.ts b/apps/api/src/api/webhooks/schemas.ts index 14e5b117..41c73bcb 100644 --- a/apps/api/src/api/webhooks/schemas.ts +++ b/apps/api/src/api/webhooks/schemas.ts @@ -1,7 +1,13 @@ import { literalUnion } from '@buzzkit/api/libs/schemas'; +import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; import { webhookDeliveryStatus } from '@buzzkit/database'; import { t } from 'elysia'; export const WebhookEventsSchema = t.Array(t.String({ minLength: 1, maxLength: 120 }), { maxItems: 100 }); export const WebhookDeliveryStatusSchema = literalUnion(webhookDeliveryStatus.enumValues); + +export const ListWebhookDeliveriesQuerySchema = t.Object({ + ...PaginationQuerySchema.properties, + status: t.Optional(WebhookDeliveryStatusSchema), +}); diff --git a/apps/api/src/api/workflows/index.ts b/apps/api/src/api/workflows/index.ts index 12473fdf..d9e76471 100644 --- a/apps/api/src/api/workflows/index.ts +++ b/apps/api/src/api/workflows/index.ts @@ -166,7 +166,11 @@ export async function updateWorkflow( insertVersion: async (tx, nextVersion) => { const [inserted] = await tx .insert(tables.workflowVersion) - .values({ workflowId: existing.id, version: nextVersion, spec: patch.spec }) + .values({ + workflowId: existing.id, + version: nextVersion, + spec: patch.spec ?? existing.latest.spec, + }) .returning(); return inserted!; }, diff --git a/apps/api/src/modules/v1/events/index.ts b/apps/api/src/modules/v1/events/index.ts index 148f364d..bf40f1ad 100644 --- a/apps/api/src/modules/v1/events/index.ts +++ b/apps/api/src/modules/v1/events/index.ts @@ -1,7 +1,5 @@ import { - EVENT_SOURCES, - EventIdSchema, - EventNameSchema, + ListEventsQuerySchema, listRecentEvents, resolveEventsBody, TrackEventsSchema, @@ -9,9 +7,7 @@ import { } from '@buzzkit/api/api/events/index'; import { auth } from '@buzzkit/api/libs/auth/index'; import { Response } from '@buzzkit/api/libs/response'; -import { literalUnion } from '@buzzkit/api/libs/schemas'; -import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; -import Elysia, { t } from 'elysia'; +import Elysia from 'elysia'; export const events = new Elysia() .use(auth) @@ -24,14 +20,7 @@ export const events = new Elysia() }, { tenant: 'events:read', - query: t.Object({ - ...PaginationQuerySchema.properties, - name: t.Optional(EventNameSchema), - source: t.Optional(literalUnion([...EVENT_SOURCES, 'webhook'] as const)), - provider: t.Optional(t.String()), - after: t.Optional(t.String({ format: 'date-time' })), - afterId: t.Optional(EventIdSchema), - }), + query: ListEventsQuerySchema, } ) .post( diff --git a/apps/api/src/modules/v1/messages/[id]/deliveries/index.ts b/apps/api/src/modules/v1/messages/[id]/deliveries/index.ts index 1d65a2e9..de60b64c 100644 --- a/apps/api/src/modules/v1/messages/[id]/deliveries/index.ts +++ b/apps/api/src/modules/v1/messages/[id]/deliveries/index.ts @@ -1,9 +1,8 @@ -import { DELIVERY_STATUSES, listDeliveries } from '@buzzkit/api/api/deliveries/index'; +import { ListMessageDeliveriesQuerySchema, listDeliveries } from '@buzzkit/api/api/deliveries/index'; import { findMessage } from '@buzzkit/api/api/messages/index'; import { auth } from '@buzzkit/api/libs/auth/index'; import { Response } from '@buzzkit/api/libs/response'; -import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; -import Elysia, { t } from 'elysia'; +import Elysia from 'elysia'; export const messageDeliveries = new Elysia() .use(auth) @@ -17,9 +16,6 @@ export const messageDeliveries = new Elysia() }, { tenant: 'messages:read', - query: t.Object({ - ...PaginationQuerySchema.properties, - status: t.Optional(t.Union(DELIVERY_STATUSES.map((status) => t.Literal(status)))), - }), + query: ListMessageDeliveriesQuerySchema, } ); diff --git a/apps/api/src/modules/v1/messages/index.ts b/apps/api/src/modules/v1/messages/index.ts index d345f24e..1e8334db 100644 --- a/apps/api/src/modules/v1/messages/index.ts +++ b/apps/api/src/modules/v1/messages/index.ts @@ -2,14 +2,13 @@ import { CreateMessageSchema, createMessage, enqueueFanout, + ListMessagesQuerySchema, listMessages, - MessageFiltersSchema, serializeMessage, } from '@buzzkit/api/api/messages/index'; import { auth } from '@buzzkit/api/libs/auth/index'; import { Response } from '@buzzkit/api/libs/response'; -import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; -import Elysia, { t } from 'elysia'; +import Elysia from 'elysia'; export const messages = new Elysia() .use(auth) @@ -25,10 +24,7 @@ export const messages = new Elysia() }, { tenant: 'messages:read', - query: t.Object({ - ...PaginationQuerySchema.properties, - ...MessageFiltersSchema.properties, - }), + query: ListMessagesQuerySchema, } ) .post( diff --git a/apps/api/src/modules/v1/runs/index.ts b/apps/api/src/modules/v1/runs/index.ts index abab7838..6f47d079 100644 --- a/apps/api/src/modules/v1/runs/index.ts +++ b/apps/api/src/modules/v1/runs/index.ts @@ -1,11 +1,9 @@ -import { listRuns, RUN_STATUSES } from '@buzzkit/api/api/runs/index'; +import { ListRunsQuerySchema, listRuns } from '@buzzkit/api/api/runs/index'; import { findWorkflowBySlug } from '@buzzkit/api/api/workflows/index'; import { auth } from '@buzzkit/api/libs/auth/index'; import { Response } from '@buzzkit/api/libs/response'; -import { literalUnion, SlugSchema } from '@buzzkit/api/libs/schemas'; import { encodeId } from '@buzzkit/api/libs/sqids'; -import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; -import Elysia, { t } from 'elysia'; +import Elysia from 'elysia'; export const runs = new Elysia() .use(auth) @@ -22,10 +20,6 @@ export const runs = new Elysia() }, { tenant: 'workflows:read', - query: t.Object({ - ...PaginationQuerySchema.properties, - status: t.Optional(literalUnion(RUN_STATUSES)), - workflow: t.Optional(SlugSchema), - }), + query: ListRunsQuerySchema, } ); diff --git a/apps/api/src/modules/v1/sources/[id]/deliveries/index.ts b/apps/api/src/modules/v1/sources/[id]/deliveries/index.ts index 1908bfa8..4700815c 100644 --- a/apps/api/src/modules/v1/sources/[id]/deliveries/index.ts +++ b/apps/api/src/modules/v1/sources/[id]/deliveries/index.ts @@ -1,8 +1,11 @@ -import { findSource, listSourceDeliveries } from '@buzzkit/api/api/sources/index'; +import { + findSource, + ListSourceDeliveriesQuerySchema, + listSourceDeliveries, +} from '@buzzkit/api/api/sources/index'; import { auth } from '@buzzkit/api/libs/auth/index'; import { Response } from '@buzzkit/api/libs/response'; -import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; -import Elysia, { t } from 'elysia'; +import Elysia from 'elysia'; export const sourceDeliveries = new Elysia() .use(auth) @@ -16,6 +19,6 @@ export const sourceDeliveries = new Elysia() }, { tenant: 'sources:read', - query: t.Object({ ...PaginationQuerySchema.properties, outcome: t.Optional(t.String()) }), + query: ListSourceDeliveriesQuerySchema, } ); diff --git a/apps/api/src/modules/v1/subscribers/index.ts b/apps/api/src/modules/v1/subscribers/index.ts index 19501c64..eba71a1b 100644 --- a/apps/api/src/modules/v1/subscribers/index.ts +++ b/apps/api/src/modules/v1/subscribers/index.ts @@ -1,8 +1,7 @@ -import { listSubscribers } from '@buzzkit/api/api/subscribers/index'; +import { ListSubscribersQuerySchema, listSubscribers } from '@buzzkit/api/api/subscribers/index'; import { auth } from '@buzzkit/api/libs/auth/index'; import { Response } from '@buzzkit/api/libs/response'; -import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; -import Elysia, { t } from 'elysia'; +import Elysia from 'elysia'; export const subscribers = new Elysia() .use(auth) @@ -15,9 +14,6 @@ export const subscribers = new Elysia() }, { tenant: 'subscribers:read', - query: t.Object({ - ...PaginationQuerySchema.properties, - search: t.Optional(t.String({ minLength: 1, maxLength: 200 })), - }), + query: ListSubscribersQuerySchema, } ); diff --git a/apps/api/src/modules/v1/workflows/[workflowSlug]/runs/index.ts b/apps/api/src/modules/v1/workflows/[workflowSlug]/runs/index.ts index a6180ac1..63923673 100644 --- a/apps/api/src/modules/v1/workflows/[workflowSlug]/runs/index.ts +++ b/apps/api/src/modules/v1/workflows/[workflowSlug]/runs/index.ts @@ -1,12 +1,10 @@ -import { listRuns, RUN_STATUSES } from '@buzzkit/api/api/runs/index'; +import { ListWorkflowRunsQuerySchema, listRuns } from '@buzzkit/api/api/runs/index'; import { findWorkflowBySlug } from '@buzzkit/api/api/workflows/index'; import { WorkflowSlugParamsSchema } from '@buzzkit/api/api/workflows/schemas'; import { auth } from '@buzzkit/api/libs/auth/index'; import { Response } from '@buzzkit/api/libs/response'; -import { literalUnion } from '@buzzkit/api/libs/schemas'; import { encodeId } from '@buzzkit/api/libs/sqids'; -import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; -import Elysia, { t } from 'elysia'; +import Elysia from 'elysia'; export const workflowRuns = new Elysia() .use(auth) @@ -21,9 +19,6 @@ export const workflowRuns = new Elysia() { tenant: 'workflows:read', params: WorkflowSlugParamsSchema, - query: t.Object({ - ...PaginationQuerySchema.properties, - status: t.Optional(literalUnion(RUN_STATUSES)), - }), + query: ListWorkflowRunsQuerySchema, } ); diff --git a/apps/api/src/modules/v1/workspaces/[workspaceSlug]/webhooks/[id]/deliveries/index.ts b/apps/api/src/modules/v1/workspaces/[workspaceSlug]/webhooks/[id]/deliveries/index.ts index 0a579f28..3095d9a4 100644 --- a/apps/api/src/modules/v1/workspaces/[workspaceSlug]/webhooks/[id]/deliveries/index.ts +++ b/apps/api/src/modules/v1/workspaces/[workspaceSlug]/webhooks/[id]/deliveries/index.ts @@ -1,13 +1,12 @@ import { findEndpoint, + ListWebhookDeliveriesQuerySchema, listWebhookDeliveries, serializeWebhookDelivery, - WebhookDeliveryStatusSchema, } from '@buzzkit/api/api/webhooks/index'; import { auth } from '@buzzkit/api/libs/auth/index'; import { Response } from '@buzzkit/api/libs/response'; -import { PaginationQuerySchema } from '@buzzkit/api/utils/pagination'; -import Elysia, { t } from 'elysia'; +import Elysia from 'elysia'; export const webhookDeliveries = new Elysia() .use(auth) @@ -23,9 +22,6 @@ export const webhookDeliveries = new Elysia() }, { scope: 'webhooks:read', - query: t.Object({ - ...PaginationQuerySchema.properties, - status: t.Optional(WebhookDeliveryStatusSchema), - }), + query: ListWebhookDeliveriesQuerySchema, } ); diff --git a/apps/api/test/api/events/track.test.ts b/apps/api/test/api/events/track.test.ts index c80413ed..8b7a5708 100644 --- a/apps/api/test/api/events/track.test.ts +++ b/apps/api/test/api/events/track.test.ts @@ -130,9 +130,9 @@ describe('resolveTimestamp', () => { }); describe('subscriberAttributes', () => { - it('returns the attributes and falls back to an empty object', () => { + it('returns the stored attributes', () => { expect(subscriberAttributes({ attributes: { plan: 'pro' } })).toEqual({ plan: 'pro' }); - expect(subscriberAttributes({ attributes: null })).toEqual({}); + expect(subscriberAttributes({ attributes: {} })).toEqual({}); }); }); diff --git a/apps/api/test/api/segments/compile.test.ts b/apps/api/test/api/segments/compile.test.ts index 1a534206..128dab6e 100644 --- a/apps/api/test/api/segments/compile.test.ts +++ b/apps/api/test/api/segments/compile.test.ts @@ -216,8 +216,8 @@ describe('compileSegment groups', () => { `(${where({ channel: 'push' })} OR ${where({ channel: 'email' })})` ); expect(where({ not: { channel: 'push' } })).toBe(`(NOT ${where({ channel: 'push' })})`); - expect(where({ all: [{ any: [{ channel: 'push' }, { not: { channel: 'sms' } }] }] })).toBe( - `((${where({ channel: 'push' })} OR (NOT ${where({ channel: 'sms' })})))` + expect(where({ all: [{ any: [{ channel: 'push' }, { not: { channel: 'email' } }] }] })).toBe( + `((${where({ channel: 'push' })} OR (NOT ${where({ channel: 'email' })})))` ); }); @@ -263,8 +263,8 @@ describe('memberQuery and countQuery', () => { }); describe('compileSegment edge cases', () => { - it('reads every channel the grammar allows, including sms', () => { - expect(where({ channel: 'sms' })).toContain("channel = 'sms'"); + it('reads every connected channel', () => { + expect(where({ channel: 'push' })).toContain("channel = 'push'"); expect(where({ channel: 'email' })).toContain("channel = 'email'"); }); diff --git a/apps/api/test/api/segments/validate.test.ts b/apps/api/test/api/segments/validate.test.ts index 94569785..99d66009 100644 --- a/apps/api/test/api/segments/validate.test.ts +++ b/apps/api/test/api/segments/validate.test.ts @@ -91,7 +91,7 @@ describe('assertExpressionShape', () => { it('counts leaves across nested groups', () => { const half = Array.from({ length: MAX_EXPRESSION_LEAVES / 2 }, () => ({ channel: 'push' as const })); expect(() => assertExpressionShape({ all: [{ any: half }, { any: half }] })).not.toThrow(); - expect(failure({ all: [{ any: half }, { any: [...half, { channel: 'sms' }] }] }).path).toBe( + expect(failure({ all: [{ any: half }, { any: [...half, { channel: 'email' }] }] }).path).toBe( `$.all[1].any[${MAX_EXPRESSION_LEAVES / 2}]` ); }); diff --git a/apps/api/test/packages/buzzkit/contract.test.ts b/apps/api/test/packages/buzzkit/contract.test.ts new file mode 100644 index 00000000..8c112fdb --- /dev/null +++ b/apps/api/test/packages/buzzkit/contract.test.ts @@ -0,0 +1,87 @@ +import { computeIdentityHash } from '@buzzkit/api/libs/identity'; +import { BuzzKit, ConfigurationError, signIdentity } from 'buzzkit'; +import { BuzzKitClient } from 'buzzkit/client'; +import { describe, expect, it } from 'vitest'; +import type { ContractParity } from './parity'; + +const TENANT_RESOURCES = [ + 'messages', + 'subscribers', + 'subscriptions', + 'topics', + 'topicCategories', + 'segments', + 'workflows', + 'runs', + 'events', + 'deliveries', + 'credentials', + 'secrets', + 'sources', + 'imports', + 'liveActivities', + 'stats', +] as const; + +describe('buzzkit client', () => { + const client = new BuzzKit({ apiKey: 'bk_ws_test', baseUrl: 'https://api.example.com' }); + + it('exposes every tenant resource on the root client and on a tenant scope', () => { + const scoped = client.tenant('acme'); + + for (const resource of TENANT_RESOURCES) { + expect(client[resource], resource).toBeTypeOf('object'); + expect(scoped[resource], resource).toBeTypeOf('object'); + } + }); + + it('exposes the workspace-scoped resources behind a slug', () => { + const workspace = client.workspace('acme'); + + expect(workspace.webhooks).toBeTypeOf('object'); + expect(workspace.members).toBeTypeOf('object'); + expect(workspace.audit).toBeTypeOf('object'); + }); + + it('refuses a workspace scope when no slug is known', () => { + expect(() => client.workspace()).toThrowError(/No workspace selected/); + }); + + it('hands out a subscriber scope without making a request', () => { + const subscriber = client.subscriber('user_123'); + + expect(subscriber.externalId).toBe('user_123'); + expect(subscriber.data).toBeNull(); + expect(subscriber.send).toBeTypeOf('function'); + expect(subscriber.track).toBeTypeOf('function'); + }); + + it('matches every API response shape it types', () => { + const parity: ContractParity[number] = true; + expect(parity).toBe(true); + }); +}); + +describe('buzzkit key kinds', () => { + it('refuses a client key on the server client', () => { + expect(() => new BuzzKit({ apiKey: 'bk_pk_public' })).toThrowError(ConfigurationError); + }); + + it('refuses a workspace or tenant key in the browser client', () => { + expect(() => new BuzzKitClient({ publishableKey: 'bk_ws_secret' })).toThrowError(ConfigurationError); + expect(() => new BuzzKitClient({ publishableKey: 'bk_tn_secret' })).toThrowError(ConfigurationError); + }); + + it('accepts a client key in the browser client', () => { + expect(new BuzzKitClient({ publishableKey: 'bk_pk_public' })).toBeInstanceOf(BuzzKitClient); + }); +}); + +describe('buzzkit identity', () => { + it('signs an identity hash the API accepts', async () => { + const signed = await signIdentity('user_123', 'tenant-identity-secret'); + const expected = await computeIdentityHash('user_123', 'tenant-identity-secret'); + + expect(signed).toBe(expected); + }); +}); diff --git a/apps/api/test/packages/buzzkit/parity.ts b/apps/api/test/packages/buzzkit/parity.ts new file mode 100644 index 00000000..472f9430 --- /dev/null +++ b/apps/api/test/packages/buzzkit/parity.ts @@ -0,0 +1,252 @@ +import type { serializeAuditEvent } from '@buzzkit/api/api/audit/index'; +import type { CredentialUploadSchema, serializeCredential } from '@buzzkit/api/api/credentials/index'; +import type { + ListMessageDeliveriesQuerySchema, + serializeAttempt, + serializeDelivery, + serializeMessageDelivery, + serializeSubscriberDelivery, +} from '@buzzkit/api/api/deliveries/index'; +import type { + EventRecord as ApiEventRecord, + TrackedEvent as ApiTrackedEvent, + ListEventsQuerySchema, + serializeEventName, + TrackEventSchema, +} from '@buzzkit/api/api/events/index'; +import type { ImportResult as ApiImportResult, ImportBodySchema } from '@buzzkit/api/api/imports/index'; +import type { SendLiveActivitySchema } from '@buzzkit/api/api/live-activities/index'; +import type { serializeMember } from '@buzzkit/api/api/members/index'; +import type { + CreateMessageSchema, + ListMessagesQuerySchema, + serializeMessage, +} from '@buzzkit/api/api/messages/index'; +import type { + RunCounts as ApiRunCounts, + RunDetail as ApiRunDetail, + RunRecord as ApiRunRecord, + ListRunsQuerySchema, + ListWorkflowRunsQuerySchema, +} from '@buzzkit/api/api/runs/index'; +import type { serializeSecret } from '@buzzkit/api/api/secrets/index'; +import type { + CreateSegmentSchema, + serializeSegment, + UpdateSegmentSchema, +} from '@buzzkit/api/api/segments/index'; +import type { + CreateSourceSchema, + ListSourceDeliveriesQuerySchema, + serializeSource, + serializeSourceDelivery, + UpdateSourceSchema, +} from '@buzzkit/api/api/sources/index'; +import type { Stats as ApiStats } from '@buzzkit/api/api/stats/index'; +import type { + ListSubscribersQuerySchema, + SubscriptionInputSchema, + serializeSubscriber, + serializeSubscriberAlias, + serializeSubscriberListItem, + serializeSubscription, +} from '@buzzkit/api/api/subscribers/index'; +import type { TenantSettings as ApiTenantSettings, serializeTenant } from '@buzzkit/api/api/tenants/index'; +import type { + SubscriberPreference as ApiSubscriberPreference, + serializeTopic, + serializeTopicCategory, +} from '@buzzkit/api/api/topics/index'; +import type { + ListWebhookDeliveriesQuerySchema, + serializeEndpoint, + serializeWebhookAttempt, + serializeWebhookDelivery, + serializeWebhookEvent, +} from '@buzzkit/api/api/webhooks/index'; +import type { + CreateWorkflowSchema, + serializeVersion, + serializeWorkflow, + UpdateWorkflowSchema, +} from '@buzzkit/api/api/workflows/index'; +import type { serializeWorkspace } from '@buzzkit/api/api/workspaces/index'; +import type { BuzzKit } from 'buzzkit'; +import type { Expect, Matches } from './wire'; + +type MessageParity = Expect>>; +type SendParity = Expect>; +type ListMessagesQuery = Expect>; +type ListMessageDeliveriesQuery = Expect< + Matches +>; +type ListSubscribersQuery = Expect< + Matches +>; +type ListEventsQuery = Expect>; +type ListRunsQuery = Expect>; +type ListWorkflowRunsQuery = Expect< + Matches +>; +type ListSourceDeliveriesQuery = Expect< + Matches +>; +type ListWebhookDeliveriesQuery = Expect< + Matches +>; +type CreateSegmentRequest = Expect>; +type UpdateSegmentRequest = Expect>; +type CreateSourceRequest = Expect>; +type UpdateSourceRequest = Expect>; +type CreateWorkflowRequest = Expect< + Matches +>; +type UpdateWorkflowRequest = Expect< + Matches +>; +type ImportRequest = Expect>; +type LiveActivityRequest = Expect< + Matches +>; +type TrackEventRequest = Expect>; +type CredentialRequest = Expect< + Matches +>; +type SubscriptionRequest = Expect< + Matches, typeof SubscriptionInputSchema.static> +>; + +type DeliveryParity = Expect>>; +type AttemptParity = Expect>>; +type MessageDeliveryParity = Expect< + Matches> +>; +type SubscriberDeliveryParity = Expect< + Matches> +>; + +type SubscriberParity = Expect>>; +type SubscriberAliasParity = Expect< + Matches> +>; +type SubscriberListParity = Expect< + Matches> +>; +type SubscriptionParity = Expect>>; + +type TopicParity = Expect>>; +type TopicCategoryParity = Expect>>; +type PreferenceParity = Expect>; + +type SegmentParity = Expect>>; + +type ApiWorkflow = ReturnType; +type OpaqueWorkflowKeys = 'spec' | 'trigger' | 'versions'; +type WorkflowParity = Expect< + Matches, Omit> +>; +type WorkflowSpecParity = Expect>; +type WorkflowTriggerParity = Expect>; +type WorkflowVersionParity = Expect>>; + +type RunParity = Expect>; +type RunDetailParity = Expect>; +type RunCountsParity = Expect>; + +type EventParity = Expect>; +type TrackedParity = Expect>; +type EventNameParity = Expect>>; + +type CredentialParity = Expect>>; +type SecretParity = Expect>>; +type ApiSource = ReturnType; +type OpaqueSourceKeys = 'mapping' | 'verification'; +type SourceParity = Expect< + Matches, Omit> +>; +type SourceMappingParity = Expect>; +type SourceVerificationParity = Expect>; +type SourceDeliveryParity = Expect< + Matches> +>; + +type TenantParity = Expect>>; +type TenantSettingsParity = Expect>; +type StatsParity = Expect>; +type ImportParity = Expect>; + +type WorkspaceParity = Expect>>; +type MemberParity = Expect>>; +type AuditParity = Expect>>; + +type WebhookParity = Expect>>; +type WebhookEventParity = Expect>>; +type WebhookDeliveryParity = Expect< + Matches> +>; +type WebhookAttemptParity = Expect< + Matches> +>; + +export type ContractParity = [ + MessageParity, + SendParity, + ListMessagesQuery, + ListMessageDeliveriesQuery, + ListSubscribersQuery, + ListEventsQuery, + ListRunsQuery, + ListWorkflowRunsQuery, + ListSourceDeliveriesQuery, + ListWebhookDeliveriesQuery, + CreateSegmentRequest, + UpdateSegmentRequest, + CreateSourceRequest, + UpdateSourceRequest, + CreateWorkflowRequest, + UpdateWorkflowRequest, + ImportRequest, + LiveActivityRequest, + TrackEventRequest, + CredentialRequest, + SubscriptionRequest, + DeliveryParity, + AttemptParity, + MessageDeliveryParity, + SubscriberDeliveryParity, + SubscriberAliasParity, + SubscriberParity, + SubscriberListParity, + SubscriptionParity, + TopicParity, + TopicCategoryParity, + PreferenceParity, + SegmentParity, + WorkflowParity, + WorkflowSpecParity, + WorkflowTriggerParity, + WorkflowVersionParity, + RunParity, + RunDetailParity, + RunCountsParity, + EventParity, + TrackedParity, + EventNameParity, + CredentialParity, + SecretParity, + SourceParity, + SourceMappingParity, + SourceVerificationParity, + SourceDeliveryParity, + TenantParity, + TenantSettingsParity, + StatsParity, + ImportParity, + WorkspaceParity, + MemberParity, + AuditParity, + WebhookParity, + WebhookEventParity, + WebhookDeliveryParity, + WebhookAttemptParity, +]; diff --git a/apps/api/test/packages/buzzkit/wire.ts b/apps/api/test/packages/buzzkit/wire.ts new file mode 100644 index 00000000..ae0afc01 --- /dev/null +++ b/apps/api/test/packages/buzzkit/wire.ts @@ -0,0 +1,35 @@ +type EncodedId = V extends number ? string : V; + +type Encoded = T extends Date + ? string + : T extends Array + ? Array> + : T extends object + ? { + [K in keyof T]: K extends 'id' | `${string}Id` ? EncodedId : Encoded; + } + : T; + +type Align = unknown extends Api + ? Sdk + : string extends keyof Api + ? Sdk + : Api extends Date + ? Api + : Api extends Array + ? Sdk extends Array + ? Array> + : Api + : Api extends object + ? Sdk extends object + ? { [K in keyof Api]: K extends keyof Sdk ? Align : Api[K] } + : Api + : Api; + +export type Expect = T; + +export type Matches, Sdk>> = [Sdk] extends [Api] + ? [Api] extends [Sdk] + ? true + : { theApiAlsoReturns: Api } + : { theSdkDoesNotMatch: Api }; diff --git a/apps/api/test/v1/client/identify/merge.test.ts b/apps/api/test/v1/client/identify/merge.test.ts index 5dde342b..29a55abf 100644 --- a/apps/api/test/v1/client/identify/merge.test.ts +++ b/apps/api/test/v1/client/identify/merge.test.ts @@ -576,7 +576,7 @@ describe('POST /v1/client/identify — anonymous merge', () => { } await eventually(async () => (await deliveriesOf(keyBearer, anon)) === 3, { label: 'all three anonymous deliveries landed', - timeoutMs: 30_000, + timeoutMs: 90_000, intervalMs: 300, }); diff --git a/apps/api/test/v1/segments/index.test.ts b/apps/api/test/v1/segments/index.test.ts index 55cc1fad..00a4b268 100644 --- a/apps/api/test/v1/segments/index.test.ts +++ b/apps/api/test/v1/segments/index.test.ts @@ -456,7 +456,6 @@ describe('segment membership', () => { await expectMembers({ not: { lastSeen: { within: '30d' } } }, ['grace']); await expectMembers({ channel: 'push' }, ['alice', 'bob', 'carol', 'erin', 'frank']); await expectMembers({ channel: 'email' }, ['dave', 'grace']); - await expectMembers({ channel: 'sms' }, []); }); it('combines groups', async () => { @@ -632,7 +631,10 @@ describe('segment membership', () => { it('completes a send to an empty segment', async () => { const slug = `seg-${uniq()}`; - await createSegment(keyBearer, { slug, expression: only({ channel: 'sms' }) }); + await createSegment(keyBearer, { + slug, + expression: only({ ref: 'attributes.plan', eq: 'nobody-has-this-plan' }), + }); const { body } = await send(keyBearer, { segment: slug }); const completed = await awaitCompletion(keyBearer, body.data!.id); expect(completed.counts.total).toBe(0); diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 4e900bc3..f69d78b0 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -11,6 +11,13 @@ "enabled": true }, + // A preview is another version of this same Worker, so it shares every binding: the production + // database, Tinybird, provider credentials, KV and the subscriber Durable Objects. It is behind + // the same auth as production, but a send issued from a preview is written to the production + // database and delivered by production's own queues, so it reaches real devices. + // BETTER_AUTH_URL and DASHBOARD_URL are vars rather than secrets (they are public URLs) so a + // preview build can point them at itself and its paired dashboard preview with `--var`. + // Without that the preview refuses the dashboard's origin and scopes cookies to buzzkit.dev. "workers_dev": true, "preview_urls": true, @@ -18,6 +25,7 @@ "ENVIRONMENT": "production", "WORKFLOW_TIME_SCALE": "1", "DASHBOARD_URL": "https://buzzkit.dev", + "BETTER_AUTH_URL": "https://api.buzzkit.dev/v1/auth", "TRACE_SAMPLE_RATIO": "1", "TINYBIRD_URL": "https://api.us-east.aws.tinybird.co" }, diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 9e1074ef..59629c6f 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -8717,7 +8717,8 @@ "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": ["event", "duplicate", "dropped", "rejected", "unverified"] } } ], @@ -13470,10 +13471,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -13741,10 +13738,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -14012,10 +14005,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -14390,10 +14379,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -14646,10 +14631,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -14902,10 +14883,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -15420,10 +15397,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -15685,10 +15658,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -15950,10 +15919,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -18804,10 +18769,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -19297,10 +19258,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } @@ -19790,10 +19747,6 @@ { "type": "string", "enum": ["email"] - }, - { - "type": "string", - "enum": ["sms"] } ] } diff --git a/apps/marketing/public/openapi.json b/apps/marketing/public/openapi.json index dfe2c1d5..650c3e90 100644 --- a/apps/marketing/public/openapi.json +++ b/apps/marketing/public/openapi.json @@ -9732,7 +9732,14 @@ "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "event", + "duplicate", + "dropped", + "rejected", + "unverified" + ] } } ], @@ -15010,12 +15017,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -15307,12 +15308,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -15604,12 +15599,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -16020,12 +16009,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -16300,12 +16283,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -16580,12 +16557,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -17148,12 +17119,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -17435,12 +17400,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -17722,12 +17681,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -20884,12 +20837,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -21419,12 +21366,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } @@ -21954,12 +21895,6 @@ "enum": [ "email" ] - }, - { - "type": "string", - "enum": [ - "sms" - ] } ] } diff --git a/apps/marketing/wrangler.jsonc b/apps/marketing/wrangler.jsonc index cb47dc1f..4a5f6693 100644 --- a/apps/marketing/wrangler.jsonc +++ b/apps/marketing/wrangler.jsonc @@ -6,6 +6,7 @@ "account_id": "e8563b02f2bf2b6a136f2781445a486f", "main": "worker/index.ts", "compatibility_date": "2026-08-01", + "preview_urls": true, "assets": { "directory": "./dist", "binding": "ASSETS", diff --git a/apps/web/app/components/badges/index.tsx b/apps/web/app/components/badges/index.tsx index 87d9a8ee..f4801036 100644 --- a/apps/web/app/components/badges/index.tsx +++ b/apps/web/app/components/badges/index.tsx @@ -192,7 +192,16 @@ export function RevokedBadge({ revoked }: { revoked: boolean }) { } export function SandboxBadge({ environment }: { environment: string }) { - return environment === 'sandbox' ? : null; + return environment === 'sandbox' ? : null; +} + +const ENVIRONMENTS: Record<'production' | 'sandbox', Entry> = { + production: { label: 'Production', tone: 'blue' }, + sandbox: { label: 'Sandbox', tone: 'amber' }, +}; + +export function EnvironmentBadge({ environment }: { environment: keyof typeof ENVIRONMENTS }) { + return ; } export function SubscriptionStatusBadge({ status }: { status: string }) { diff --git a/apps/web/app/lib/actions/members.server.ts b/apps/web/app/lib/actions/members.server.ts index 5edfe3aa..ca8d3d20 100644 --- a/apps/web/app/lib/actions/members.server.ts +++ b/apps/web/app/lib/actions/members.server.ts @@ -1,3 +1,4 @@ +import { MEMBER_ROLES } from 'buzzkit'; import type { ActionFunctionArgs } from 'react-router'; import { beginAction } from '@/app/lib/actions/context.server'; import { @@ -9,7 +10,6 @@ import { updateMemberRole, } from '@/app/lib/api.server'; -const ROLES = ['member', 'admin', 'owner'] as const; const INVITE_ROLES = ['member', 'admin'] as const; export async function membersAction(args: ActionFunctionArgs) { @@ -20,7 +20,7 @@ export async function membersAction(args: ActionFunctionArgs) { try { switch (intent) { case 'role': { - const role = ROLES.find((entry) => entry === form.get('role')); + const role = MEMBER_ROLES.find((entry) => entry === form.get('role')); if (!id || !role) return { error: 'Pick a role.' }; await updateMemberRole(ctx, token, slug, id, role); return { ok: true }; diff --git a/apps/web/app/lib/api.server.ts b/apps/web/app/lib/api.server.ts index 8c1152be..69e37b1d 100644 --- a/apps/web/app/lib/api.server.ts +++ b/apps/web/app/lib/api.server.ts @@ -1,6 +1,7 @@ import { createVersionedClient, type VersionedApiClient } from '@buzzkit/eden'; import type { ImportRow } from '@buzzkit/schema/imports'; import type { TriggerSource, WorkflowSpec } from '@buzzkit/schema/workflows'; +import type { BuzzKit } from 'buzzkit'; import type { Expression } from 'buzzkit/expressions'; import { data } from 'react-router'; import { signedOutRedirect } from '@/app/lib/session.server'; @@ -431,7 +432,7 @@ export function listSourceDeliveries( workspaceSlug: string, tenantSlug: string, id: string, - query: { limit?: number; cursor?: string; outcome?: string } = {} + query: BuzzKit.ListSourceDeliveriesParams = {} ) { return unwrap( ctx, diff --git a/apps/web/app/routes/[slug]/keys/index.tsx b/apps/web/app/routes/[slug]/keys/index.tsx index d6ada57a..8502fdb9 100644 --- a/apps/web/app/routes/[slug]/keys/index.tsx +++ b/apps/web/app/routes/[slug]/keys/index.tsx @@ -26,6 +26,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { toast } from '@buzzkit/ui/components/sonner'; import { Table, TableBody, TableCell, TablePagination, TableRow } from '@buzzkit/ui/components/table'; import { Tooltip, TooltipContent, TooltipTrigger } from '@buzzkit/ui/components/tooltip'; +import type { BuzzKit } from 'buzzkit'; import { useEffect, useState } from 'react'; import { useOutletContext } from 'react-router'; import { cloudflareContext } from '@/app/cloudflare'; @@ -44,7 +45,6 @@ import { paginate, readPage } from '@/app/lib/utils/pagination'; import type { WorkspaceOutletContext } from '@/app/routes/[slug]/layout'; import type { Route } from './+types/index'; -type KeyKind = 'workspace' | 'tenant' | 'client'; type Preset = 'full' | 'read' | 'custom'; type KeyScopeGroup = ScopeGroup & { wildcard: string; tenant: boolean }; @@ -80,7 +80,7 @@ const SCOPE_GROUPS: KeyScopeGroup[] = [ { label: 'messages', wildcard: 'messages:*', options: ['messages:read', 'messages:send'], tenant: true }, ]; -const KINDS: { value: KeyKind; label: string }[] = [ +const KINDS: { value: BuzzKit.KeyKind; label: string }[] = [ { value: 'workspace', label: 'Workspace' }, { value: 'tenant', label: 'Tenant' }, { value: 'client', label: 'Client' }, @@ -124,11 +124,11 @@ export function loader({ request, context, params }: Route.LoaderArgs) { export const action = keysAction; -function groupsFor(kind: KeyKind): KeyScopeGroup[] { +function groupsFor(kind: BuzzKit.KeyKind): KeyScopeGroup[] { return kind === 'tenant' ? SCOPE_GROUPS.filter((group) => group.tenant) : SCOPE_GROUPS; } -function firstUseSnippet(apiUrl: string, kind: KeyKind, secret: string) { +function firstUseSnippet(apiUrl: string, kind: BuzzKit.KeyKind, secret: string) { if (kind === 'client') { return [ `curl -X POST ${apiUrl}/v1/client/identify \\`, @@ -158,14 +158,14 @@ function KeyDialog({ }) { const defaultTenant = tenants.find((entry) => entry.isDefault)?.slug ?? tenants[0]?.slug ?? ''; const [name, setName] = useState(''); - const [kind, setKind] = useState('workspace'); + const [kind, setKind] = useState('workspace'); const [tenant, setTenant] = useState(defaultTenant); const [preset, setPreset] = useState('full'); const [scopes, setScopes] = useState([]); - const [created, setCreated] = useState<{ secret: string; kind: KeyKind } | null>(null); + const [created, setCreated] = useState<{ secret: string; kind: BuzzKit.KeyKind } | null>(null); const { submit, pending } = useActionFetcher((data) => { if (typeof data.secret === 'string') - setCreated({ secret: data.secret, kind: (data.kind as KeyKind) ?? 'workspace' }); + setCreated({ secret: data.secret, kind: (data.kind as BuzzKit.KeyKind) ?? 'workspace' }); else onOpenChange(false); }); @@ -230,7 +230,11 @@ function KeyDialog({ Type - setKind(value as BuzzKit.KeyKind)} + > diff --git a/apps/web/app/routes/[slug]/settings/channels/index.tsx b/apps/web/app/routes/[slug]/settings/channels/index.tsx index d1c2a964..be1c2200 100644 --- a/apps/web/app/routes/[slug]/settings/channels/index.tsx +++ b/apps/web/app/routes/[slug]/settings/channels/index.tsx @@ -27,7 +27,7 @@ import { Switch } from '@buzzkit/ui/components/switch'; import { useState } from 'react'; import { useLocation, useOutletContext } from 'react-router'; import { cloudflareContext } from '@/app/cloudflare'; -import { CredentialStatusBadge, SandboxBadge } from '@/app/components/badges'; +import { CredentialStatusBadge, EnvironmentBadge } from '@/app/components/badges'; import { PageHeader } from '@/app/components/layout/page-header'; import { Deferred } from '@/app/components/loading/deferred'; import type { PageHandle } from '@/app/components/loading/handle'; @@ -62,6 +62,8 @@ const DETAIL_LABELS: Record = { clientEmail: 'Service account', }; +const ENVIRONMENTS: Array = ['production', 'sandbox']; + export function meta() { return [{ title: 'Channels · BuzzKit' }]; } @@ -105,6 +107,13 @@ function detailsOf(credentials: Credential[]): string | null { return parts.length > 0 ? parts.join(' · ') : null; } +function environmentsOf(credentials: Credential[]): Array { + if (credentials[0]?.provider !== 'apns') return []; + return ENVIRONMENTS.filter((environment) => + credentials.some((credential) => credential.environment === environment) + ); +} + function ProviderRow({ channel, provider, @@ -140,9 +149,9 @@ function ProviderRow({ title={ {provider.name} - {credentials.some((credential) => credential.environment === 'sandbox') && ( - - )} + {environmentsOf(credentials).map((environment) => ( + + ))} {!provider.available && Soon} } diff --git a/apps/web/app/routes/[slug]/sources/[id]/index.tsx b/apps/web/app/routes/[slug]/sources/[id]/index.tsx index 4091f79a..739da5e9 100644 --- a/apps/web/app/routes/[slug]/sources/[id]/index.tsx +++ b/apps/web/app/routes/[slug]/sources/[id]/index.tsx @@ -1,11 +1,11 @@ import { - DELIVERY_OUTCOMES, detectProvider, isSourceMapping, lintSourceMapping, type MappedEvent, mapPayload, readPath, + SOURCE_DELIVERY_OUTCOMES, SOURCE_PRESETS, type SourceMapping, STANDARD_WEBHOOK_HEADERS, @@ -163,7 +163,7 @@ export async function loader({ request, context, params }: Route.LoaderArgs) { const tenant = await resolveTenant(request, params.slug); const ctx = { request, env }; const requested = requestUrl(request).searchParams.get('outcome') ?? ''; - const outcome = (DELIVERY_OUTCOMES as readonly string[]).includes(requested) ? requested : undefined; + const outcome = SOURCE_DELIVERY_OUTCOMES.find((value) => value === requested); return { setup: requestUrl(request).searchParams.has('setup'), outcomeFilter: outcome ?? 'all', diff --git a/bun.lock b/bun.lock index 5577a958..4745cbd7 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,8 @@ "devDependencies": { "@babel/parser": "^8.0.4", "@biomejs/biome": "2.5.9", + "@changesets/changelog-github": "^1.0.1", + "@changesets/cli": "^3.0.2", "@types/node": "^26.4.0", "husky": "^9.1.7", "lint-staged": "^17.2.0", @@ -138,19 +140,33 @@ }, "packages/buzzkit": { "name": "buzzkit", - "version": "0.0.0", + "version": "0.1.0", "devDependencies": { + "@testing-library/react": "^16", "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^4.1.11", + "jsdom": "^27", "publint": "^0.3.24", "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsdown": "^0.23.0", "typescript": "^7.0.2", "vitest": "~4.1.10", }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + }, + "optionalPeers": [ + "react", + ], }, "packages/database": { "name": "@buzzkit/database", "version": "0.0.0", "dependencies": { + "@buzzkit/schema": "workspace:*", + "buzzkit": "workspace:*", "postgres": "^3.4.9", }, "devDependencies": { @@ -254,6 +270,8 @@ }, }, "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -282,6 +300,12 @@ "@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.4.0", "", { "optionalDependencies": { "@astrojs/compiler-binding-darwin-arm64": "0.4.0", "@astrojs/compiler-binding-darwin-x64": "0.4.0", "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.0", "@astrojs/compiler-binding-linux-arm64-musl": "0.4.0", "@astrojs/compiler-binding-linux-x64-gnu": "0.4.0", "@astrojs/compiler-binding-linux-x64-musl": "0.4.0", "@astrojs/compiler-binding-wasm32-wasi": "0.4.0", "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.0", "@astrojs/compiler-binding-win32-x64-msvc": "0.4.0" } }, "sha512-x2RjDUuWfwLNtc3mjAdSRInwqh/rqbLar9cm/5FOMbHvmYZB7yfKewzSclAxWjIZsypJDXv1lhaP2WG+P8TK3g=="], "@astrojs/compiler-binding-darwin-arm64": ["@astrojs/compiler-binding-darwin-arm64@0.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZVUwHundaQyFNjE6uoa0usaC0WOCitDCLS/4mdb4rOiJXwVUuKJBMxI5WMzXLWmamsXtK/Z//ifLXvV5Yeh4Hw=="], @@ -480,6 +504,40 @@ "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@8.1.0", "", { "dependencies": { "@changesets/config": "^4.0.0", "@changesets/format": "^0.1.2", "@changesets/git": "^4.0.1", "@changesets/should-skip-package": "^1.0.0", "@changesets/types": "^7.0.0", "import-meta-resolve": "^4.2.0", "jsonc-parser": "^3.3.1", "semver": "^7.8.1" } }, "sha512-M93HOGyX3ssg6He3b5NotaHtQVu6uajHS6UIQ28xKt2RzskCy73ayX4I914qBKjTJmrE4YFlELQjfcqxbmGLJw=="], + + "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@7.0.0", "", { "dependencies": { "@changesets/errors": "^1.0.0", "@changesets/get-dependents-graph": "^3.0.0", "@changesets/should-skip-package": "^1.0.0", "@changesets/types": "^7.0.0", "semver": "^7.8.1" } }, "sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg=="], + + "@changesets/changelog-git": ["@changesets/changelog-git@1.0.0", "", { "dependencies": { "@changesets/types": "^7.0.0" } }, "sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA=="], + + "@changesets/changelog-github": ["@changesets/changelog-github@1.0.1", "", { "dependencies": { "@changesets/get-github-info": "^1.0.1", "@changesets/types": "^7.0.0" } }, "sha512-QUcrV7878yHlWPXXPA6gqSxNKwtVHCd1K22L7ZAoJw2yGy8K4QvjwOStD75+1Q9KD9ZSUgGS94HuYdd7HOVtpA=="], + + "@changesets/cli": ["@changesets/cli@3.0.2", "", { "dependencies": { "@changesets/apply-release-plan": "^8.1.0", "@changesets/assemble-release-plan": "^7.0.0", "@changesets/changelog-git": "^1.0.0", "@changesets/config": "^4.0.0", "@changesets/errors": "^1.0.0", "@changesets/get-dependents-graph": "^3.0.0", "@changesets/git": "^4.0.1", "@changesets/pre": "^3.0.0", "@changesets/read": "^1.0.1", "@changesets/should-skip-package": "^1.0.0", "@changesets/types": "^7.0.0", "@changesets/write": "^1.0.1", "@clack/prompts": "^1.7.0", "@manypkg/get-packages": "^3.1.0", "@pnpm/deps.graph-sequencer": "^1100.0.1", "cac": "^7.0.0", "import-meta-resolve": "^4.2.0", "launch-editor": "^2.14.1", "package-manager-detector": "^1.6.0", "semver": "^7.8.1", "tinyexec": "^1.3.0" }, "bin": { "changeset": "bin.js" } }, "sha512-t/omGJj/I+Jv0kmJAkj5cstYEdUQiJnpip2F+2m3F4lQ3kAZ3o8Exep4/Fm1qq4rvw6ovlhJvZNrKdwLJ4lkuQ=="], + + "@changesets/config": ["@changesets/config@4.0.0", "", { "dependencies": { "@changesets/get-dependents-graph": "^3.0.0", "@changesets/should-skip-package": "^1.0.0", "@changesets/types": "^7.0.0", "@manypkg/get-packages": "^3.1.0", "picomatch": "^4.0.4" } }, "sha512-mw95/YrkOuhZZxfnVAA4bSXOFUi+KlhzOBTM8C4x777NhUU6HWIl9Z+K+nME+E4PVsv5NQVQwTfiHihAS1A/ow=="], + + "@changesets/errors": ["@changesets/errors@1.0.0", "", {}, "sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g=="], + + "@changesets/format": ["@changesets/format@0.1.2", "", { "dependencies": { "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.0" } }, "sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA=="], + + "@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@3.0.0", "", { "dependencies": { "@changesets/types": "^7.0.0", "semver": "^7.8.1" } }, "sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg=="], + + "@changesets/get-github-info": ["@changesets/get-github-info@1.0.1", "", { "dependencies": { "dataloader": "^2.2.3" } }, "sha512-ifLQCky/ZtDgZ4xCiUMjnLZSJ8xk52iIzMJbBBPlZWjr2CiTNTaTDddIVWKicrUAdWuLWL7PUw2fNa1yPwLpIg=="], + + "@changesets/git": ["@changesets/git@4.0.1", "", { "dependencies": { "@changesets/errors": "^1.0.0", "@changesets/types": "^7.0.0", "@manypkg/get-packages": "^3.1.0", "picomatch": "^4.0.4", "tinyexec": "^1.3.0" } }, "sha512-6vWpIAC4LkpmlqaIu37ViT5enyd9+uBwAiGqCGhASvkSHBgx4PrtTGXWTMFYpDmZbjgyonyVgMciPrW4MqtJsA=="], + + "@changesets/parse": ["@changesets/parse@1.0.0", "", { "dependencies": { "@changesets/types": "^7.0.0", "yaml": "^2.9.0" } }, "sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg=="], + + "@changesets/pre": ["@changesets/pre@3.0.0", "", { "dependencies": { "@changesets/errors": "^1.0.0", "@changesets/types": "^7.0.0", "@manypkg/get-packages": "^3.1.0" } }, "sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q=="], + + "@changesets/read": ["@changesets/read@1.0.1", "", { "dependencies": { "@changesets/git": "^4.0.1", "@changesets/parse": "^1.0.0", "@changesets/types": "^7.0.0" } }, "sha512-nzvSC6RcTiWf/dsuwZFXpLzGGsRHYCL68U3uHV7srsulU93aVWY7u2GEJiDZ+4GIIElfaW5EqtKC0UHroY+LTQ=="], + + "@changesets/should-skip-package": ["@changesets/should-skip-package@1.0.0", "", { "dependencies": { "@changesets/types": "^7.0.0" } }, "sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg=="], + + "@changesets/types": ["@changesets/types@7.0.0", "", {}, "sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w=="], + + "@changesets/write": ["@changesets/write@1.0.1", "", { "dependencies": { "@changesets/format": "^0.1.1", "@changesets/types": "^7.0.0", "human-id": "^4.2.0" } }, "sha512-q/ThtP9gcnEP6xlv7LrY26C2mHqdGBti/MmOVngt/M/oIGYkssmQGxPK9WzBNt2juVcH/vml2WQ+ra8LXYOTaA=="], + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], @@ -504,6 +562,18 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.2", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.12", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], @@ -580,6 +650,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -700,6 +772,12 @@ "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@manypkg/find-root": ["@manypkg/find-root@3.1.0", "", { "dependencies": { "@manypkg/tools": "^2.1.0" } }, "sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA=="], + + "@manypkg/get-packages": ["@manypkg/get-packages@3.1.0", "", { "dependencies": { "@manypkg/find-root": "^3.1.0", "@manypkg/tools": "^2.1.0" } }, "sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg=="], + + "@manypkg/tools": ["@manypkg/tools@2.1.2", "", { "dependencies": { "jju": "^1.4.0", "tinyglobby": "^0.2.13", "yaml": "^2.9.0" } }, "sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], @@ -874,6 +952,8 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], + "@pnpm/deps.graph-sequencer": ["@pnpm/deps.graph-sequencer@1100.0.1", "", {}, "sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -904,6 +984,8 @@ "@puppeteer/browsers": ["@puppeteer/browsers@2.7.1", "", { "dependencies": { "debug": "^4.4.0", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.0", "tar-fs": "^3.0.8", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-MK7rtm8JjaxPN7Mf1JdZIZKPD2Z+W7osvrC1vjpvfOX1K0awDIHYbNi89f7eotp7eMUn2shWnt03HwVbriXtKQ=="], + "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], @@ -968,35 +1050,35 @@ "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.7", "", { "os": "android", "cpu": "arm" }, "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.7", "", { "os": "android", "cpu": "arm64" }, "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.7", "", { "os": "linux", "cpu": "arm" }, "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.7", "", { "os": "none", "cpu": "arm64" }, "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw=="], "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], @@ -1160,6 +1242,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + "@tinybirdco/sdk": ["@tinybirdco/sdk@0.0.82", "", { "dependencies": { "@clack/prompts": "^1.0.0", "chokidar": "^4.0.0", "commander": "^12.0.0", "dotenv": "^16.0.0", "esbuild": "^0.25.0", "picocolors": "^1.1.1", "zod": "^3.25.0" }, "bin": { "tinybird": "bin/tinybird.js" } }, "sha512-aG8LNE0FGJWlAuOvypi6rXwaLGLnBKB3dQMcQa4EH/4LKxsJ2qrDiQaTf9flaSrpktPhL/bNzhYgIF5/Nh9d/A=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], @@ -1184,6 +1270,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1350,6 +1438,56 @@ "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "@yuku-codegen/binding-android-arm64": ["@yuku-codegen/binding-android-arm64@0.9.4", "", { "os": "android", "cpu": "arm64" }, "sha512-6ViGFAr+N2Yjnc8wBx16wJZGB0pGmClILO+FUC3kzwEydfjXQovWEdcFyl98RVtWdj9ahS41XXe2zKFl+L3uWg=="], + + "@yuku-codegen/binding-darwin-arm64": ["@yuku-codegen/binding-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0BTB70ha+wRsz6fI5HosevJpMmvgndmf+6ND1OneFKCrWhNzX/2xIQ36Z8rUgv5bpnWjKl6bRvzSFV3PU+WOlQ=="], + + "@yuku-codegen/binding-darwin-x64": ["@yuku-codegen/binding-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-9QVVqq8LCD6v+Gl099u32YJ0999HBCoe09ecIeifKTi4Y1azX44DkF9q9pP2lxgwRUvR3GmWl5k1pLe3ojR2rg=="], + + "@yuku-codegen/binding-freebsd-x64": ["@yuku-codegen/binding-freebsd-x64@0.9.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Qm3ky33Em9w1XVSryq+D/arLVapeqp0Bu1719UGV6olmfp4kTD+NfsLG9bRx5hOJSu1Zl3r9LbyTP1oTmcHAsg=="], + + "@yuku-codegen/binding-linux-arm-gnu": ["@yuku-codegen/binding-linux-arm-gnu@0.9.4", "", { "os": "linux", "cpu": "arm" }, "sha512-euKmS7pnkeJylEN6+9bfu0jHtGfbQRfFBIpmTe8YVnWzQ5Rjyrns13+1kao2RSS9DNkucX1RXIdiTvQoDUXF0w=="], + + "@yuku-codegen/binding-linux-arm-musl": ["@yuku-codegen/binding-linux-arm-musl@0.9.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7JTizi2O+ks9/dLPDLYmVLsYQUC9Fxu0e6LNA+F6FFQ/sV2o7owBb1eTccMKkgauWGGUpJYQvlXrCKpGwguvhA=="], + + "@yuku-codegen/binding-linux-arm64-gnu": ["@yuku-codegen/binding-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-f7eLYDwmcz56Jzvp8UmXUehFzTAn6hEHt64GDWNWGSqrphHRvWLEshQ+sQv8DfNpkXFaOQ49erl4gb2Wm0JM+A=="], + + "@yuku-codegen/binding-linux-arm64-musl": ["@yuku-codegen/binding-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bLdjCgo+u3dU3HH/t+WZ8FsAo2IgdOzruTHZ+XTFquj6j//QJjH9xfyRtu7h65lCT3CQVW9aGEe6/A/HPyA9KQ=="], + + "@yuku-codegen/binding-linux-x64-gnu": ["@yuku-codegen/binding-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-hDMB71QCDx2AAxhlCBgakngMAa4KK6f6rcTt9v8nrzoXs681cmZultGeZk0tjONxli+N+VMhXQUoiVCSJuKiQg=="], + + "@yuku-codegen/binding-linux-x64-musl": ["@yuku-codegen/binding-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-uk1hOZA79AqziAH9A/eSD05VchNRdnLb6zxFGHhCbLYeeGYL+KOEL0+kCIPtaKuQ7wdxDOooiKE1D0jpaWQ0aA=="], + + "@yuku-codegen/binding-win32-arm64": ["@yuku-codegen/binding-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-IbZuNVrctjts5lAKUEd9ppnzvylaPdYBTv8AVDtY6EyQ5hpgUfsReqbqODJVGpn2/uIk1fa1jWP012orPoiK+A=="], + + "@yuku-codegen/binding-win32-x64": ["@yuku-codegen/binding-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UqC4C0PlVAPEQ8fYr0AamuW2zKAkqQFiaaQ88Rl3YlzSNlshj+VmDoSbJRw4TFYL22+Vw53zDSJ95aXyjJN/aw=="], + + "@yuku-parser/binding-android-arm64": ["@yuku-parser/binding-android-arm64@0.9.4", "", { "os": "android", "cpu": "arm64" }, "sha512-x+dZef5ulszaYzgjHLUgnhDXAgJsGfodl30JuTinfLN5mEyB50burpUuyUqufGQp0fpI/sHLAN3V8fuJteFEQQ=="], + + "@yuku-parser/binding-darwin-arm64": ["@yuku-parser/binding-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bSpjVPP8zTTcMZi78gxPkZZhVNbg4Doqsb2CrxHR2g+vZxP7Ey4GVzgD5nVYSHgS1y8dGULGW8AhPrfRWFEF6w=="], + + "@yuku-parser/binding-darwin-x64": ["@yuku-parser/binding-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-QNswJLBxsoDkghztrF0bUy+DEUoeX1lS4cL3dCM1IB4hfLoZ3Jf3jmCoggDnqN++sJ91F6w8oHiyQ4ZhUEr/bg=="], + + "@yuku-parser/binding-freebsd-x64": ["@yuku-parser/binding-freebsd-x64@0.9.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-D4rmGI0EOwxmKsv3+iGQDmtJ9/Oqr9wPxsQpONZ+QmxHqNbl+08XaJkhuC5wAg17bj6afcpdQfiIAoSMBY17Rw=="], + + "@yuku-parser/binding-linux-arm-gnu": ["@yuku-parser/binding-linux-arm-gnu@0.9.4", "", { "os": "linux", "cpu": "arm" }, "sha512-rZuqBxlaRnNY57nawRP1LPx4i+ieCrvIQkBsbGu/xjWkZGzFCrHPliy5YiMxp3Mw4v5KftAlDJx7c9jApNk2ww=="], + + "@yuku-parser/binding-linux-arm-musl": ["@yuku-parser/binding-linux-arm-musl@0.9.4", "", { "os": "linux", "cpu": "arm" }, "sha512-0PRDvRZvh18Q1lADxs0RMtqYHTd3dD/o1WEnMO/K8o7V9Gmy/7nD3/HjGQCxFPxkHTp41toF+DsXgEandAc2Gw=="], + + "@yuku-parser/binding-linux-arm64-gnu": ["@yuku-parser/binding-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CT1I24KFA/Tv/Exi1f+CVxafUIjlKm3gc51HbWLW7yf7OvJKCvKSja+EB17sDtYsS5a2EB9BTr8I5fPuDJ8rFA=="], + + "@yuku-parser/binding-linux-arm64-musl": ["@yuku-parser/binding-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-DP193Z3nUXQ92AwmmkIAhVLxv2qj8rWAAAODownCwUk0wKrmnyH9km9Gd4NSmntBYG/Kv0P5btN6qcO1JXgEdw=="], + + "@yuku-parser/binding-linux-x64-gnu": ["@yuku-parser/binding-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tjoxmuNz+xmSt/Or+US5aaKpRZcptQsWYaPtDp1/0QUvhyg8VS6mOrizKkzVBJKYVNZ7eDTleOBquYVm+Haz6w=="], + + "@yuku-parser/binding-linux-x64-musl": ["@yuku-parser/binding-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-kHXf72EM6oHN58Swwruf9plXlCVY4mOlgqIjpXenEAY87cVcH3VxMZdCQG7rIKa+k0Hf3E/WS/8ufqZSCER7Gw=="], + + "@yuku-parser/binding-win32-arm64": ["@yuku-parser/binding-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yj6+bbGDZrLbzp1Qr7hiaIjYagslUrRy6t3TIxfiXUeJWNH8ydx7birM0UJiIInqUld0mk9qBz1kei/T8gcKvw=="], + + "@yuku-parser/binding-win32-x64": ["@yuku-parser/binding-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-B9D+Z1/jVfoEd1SgbuaYTPH6onVlSsSaK151mW8Rb6Yv+OrTOocNnv2w3peBicS6MB3VI+mJG8YqxRI4ONd1OQ=="], + + "@yuku-toolchain/types": ["@yuku-toolchain/types@0.9.4", "", {}, "sha512-dqnMswJWE7YBbPEvGstgFlWFTRn20V/Hu82XTDjWxzaU18rGpxBqxhKlBlgyWrPARRvNLIqY2XA6S9edR+2AsA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.11.2", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w=="], @@ -1360,7 +1498,7 @@ "adm-zip": ["adm-zip@0.6.0", "", {}, "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg=="], - "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agents": ["agents@0.21.0", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^8.0.2", "@cfworker/json-schema": "^4.1.1", "@rolldown/plugin-babel": "^0.2.3", "cron-schedule": "^6.0.0", "esbuild": "^0.28.1", "mimetext": "^3.0.28", "nanoid": "^5.1.16", "partyserver": "^0.5.9", "partysocket": "1.3.0", "yaml": "^2.9.0", "yargs": "^18.0.0" }, "peerDependencies": { "@ai-sdk/react": "^3.0.0 || ^4.0.0", "@cloudflare/codemode": ">=0.5.0", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/sdk": "1.30.0", "@modelcontextprotocol/server": "2.0.0", "@tanstack/ai": ">=0.10.2 <1.0.0", "@x402/core": "^2.0.0", "@x402/evm": "^2.0.0", "ai": "^6.0.0 || ^7.0.0", "chat": "^4.29.0", "just-bash": "^3.0.0", "react": "^19.0.0", "vite": ">=6.0.0 <9.0.0", "zod": "^4.0.0" }, "optionalPeers": ["@ai-sdk/react", "@cloudflare/codemode", "@tanstack/ai", "@x402/core", "@x402/evm", "ai", "chat", "just-bash", "react", "vite"], "bin": { "agents": "dist/cli/index.js" } }, "sha512-8A048JJFMog7t68NrIQOT9CGcQ9O+h8NpP/Udw2kd4fAHvDn2T/+3Do85XT+xGj2VpciGbW+58RwLpVM9AA9eA=="], @@ -1468,6 +1606,8 @@ "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -1498,6 +1638,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "cacheable-lookup": ["cacheable-lookup@7.0.0", "", {}, "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w=="], "cacheable-request": ["cacheable-request@10.2.14", "", { "dependencies": { "@types/http-cache-semantics": "^4.0.2", "get-stream": "^6.0.1", "http-cache-semantics": "^4.1.1", "keyv": "^4.5.3", "mimic-response": "^4.0.0", "normalize-url": "^8.0.0", "responselike": "^3.0.0" } }, "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ=="], @@ -1636,6 +1778,8 @@ "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + "cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -1662,18 +1806,24 @@ "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "dataloader": ["dataloader@2.2.3", "", {}, "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decode-bmp": ["decode-bmp@0.2.1", "", { "dependencies": { "@canvas/image-data": "^1.0.0", "to-data-view": "^1.1.0" } }, "sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA=="], "decode-ico": ["decode-ico@0.4.1", "", { "dependencies": { "@canvas/image-data": "^1.0.0", "decode-bmp": "^0.2.0", "to-data-view": "^1.1.0" } }, "sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA=="], @@ -1740,6 +1890,8 @@ "dns-socket": ["dns-socket@4.2.2", "", { "dependencies": { "dns-packet": "^5.2.4" } }, "sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -1758,6 +1910,8 @@ "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -1770,6 +1924,8 @@ "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -1782,7 +1938,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -2082,6 +2238,10 @@ "hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="], + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], @@ -2094,7 +2254,9 @@ "http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], - "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-id": ["human-id@4.2.1", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -2112,6 +2274,10 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], @@ -2206,6 +2372,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -2248,6 +2416,8 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], + "jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="], "js-base64": ["js-base64@3.9.3", "", {}, "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g=="], @@ -2256,6 +2426,8 @@ "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="], + "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -2292,6 +2464,8 @@ "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], + "launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="], + "lcm": ["lcm@0.0.3", "", { "dependencies": { "gcd": "^0.0.1" } }, "sha512-TB+ZjoillV6B26Vspf9l2L/vKaRY/4ep3hahcyVkCGFgsTNRUQdc24bQeNFiZeoxH0vr5+7SfNRMQuPHv/1IrQ=="], "leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="], @@ -2344,6 +2518,8 @@ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], @@ -2654,7 +2830,7 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -2724,6 +2900,8 @@ "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -2752,12 +2930,16 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "puppeteer": ["puppeteer@24.3.1", "", { "dependencies": { "@puppeteer/browsers": "2.7.1", "chromium-bidi": "2.1.2", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1402036", "puppeteer-core": "24.3.1", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-k0OJ7itRwkr06owp0CP3f/PsRD7Pdw4DjoCUZvjGr+aNgS1z6n/61VajIp0uBjl+V5XAQO1v/3k9bzeZLWs9OQ=="], "puppeteer-core": ["puppeteer-core@24.3.1", "", { "dependencies": { "@puppeteer/browsers": "2.7.1", "chromium-bidi": "2.1.2", "debug": "^4.4.0", "devtools-protocol": "0.0.1402036", "typed-query-selector": "^2.12.0", "ws": "^8.18.1" } }, "sha512-585ccfcTav4KmlSmYbwwOSeC8VdutQHn2Fuk0id/y/9OoeO7Gg5PK1aUGdZjEmos0TAq+pCpChqFurFbpNd3wA=="], "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], @@ -2776,6 +2958,8 @@ "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -2874,7 +3058,9 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], + "rolldown": ["rolldown@1.2.7", "", { "dependencies": { "@oxc-project/types": "=0.148.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.7", "@rolldown/binding-android-arm64": "1.2.7", "@rolldown/binding-darwin-arm64": "1.2.7", "@rolldown/binding-darwin-x64": "1.2.7", "@rolldown/binding-freebsd-x64": "1.2.7", "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", "@rolldown/binding-linux-arm64-gnu": "1.2.7", "@rolldown/binding-linux-arm64-musl": "1.2.7", "@rolldown/binding-linux-ppc64-gnu": "1.2.7", "@rolldown/binding-linux-s390x-gnu": "1.2.7", "@rolldown/binding-linux-x64-gnu": "1.2.7", "@rolldown/binding-linux-x64-musl": "1.2.7", "@rolldown/binding-openharmony-arm64": "1.2.7", "@rolldown/binding-win32-arm64-msvc": "1.2.7", "@rolldown/binding-win32-x64-msvc": "1.2.7" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig=="], + + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.28.5", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.6", "obug": "^2.1.4", "yuku-ast": "^0.9.3", "yuku-codegen": "^0.9.3", "yuku-parser": "^0.9.3" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.2.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-yYd3C9CeJwqjOc9X23m0Tyxcqic491uLZlfg51szT287S8zCCqLR2uoySoElgqy2CLn7PdXcEo1dlkBs4n1WHg=="], "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], @@ -2910,6 +3096,8 @@ "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -2940,6 +3128,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + "sherif": ["sherif@1.13.0", "", { "optionalDependencies": { "sherif-darwin-arm64": "1.13.0", "sherif-darwin-x64": "1.13.0", "sherif-linux-arm64": "1.13.0", "sherif-linux-arm64-musl": "1.13.0", "sherif-linux-x64": "1.13.0", "sherif-linux-x64-musl": "1.13.0", "sherif-windows-arm64": "1.13.0", "sherif-windows-x64": "1.13.0" }, "bin": { "sherif": "index.js" } }, "sha512-Ld2nUOlwW1nmYDA2Q/5o7SC8WcCzVS7XjImmzW4a4z1o8DXJnt+2xYLvI42N5UYlNb/EevPahdC/XxIP6C38TQ=="], "sherif-darwin-arm64": ["sherif-darwin-arm64@1.13.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k38jpGpZIEWS5dpSRLSvVi53LZuGO3hDqB88jgdLMDN11LZM6yTDcP0GytnQ8OpE6Br/Js6bDCLsdWDarZdV9g=="], @@ -3070,6 +3260,8 @@ "svgo": ["svgo@4.1.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^6.0.0", "css-tree": "^3.0.1", "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" } }, "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -3104,12 +3296,16 @@ "tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="], - "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "tldts": ["tldts@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw=="], + + "tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="], + "to-data-view": ["to-data-view@1.1.0", "", {}, "sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -3118,7 +3314,11 @@ "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -3136,6 +3336,8 @@ "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + "tsdown": ["tsdown@0.23.0", "", { "dependencies": { "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.7", "rolldown": "~1.2.7", "rolldown-plugin-dts": "^0.28.5", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.4.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.23.0", "@tsdown/exe": "0.23.0", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": ">=0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-BaT+ep1xnj5hdyICLd5r5SYYE+TXnI1ATePLykv9vcKpHpFTWUWRH+x1AF/E2qcu3YB6+vI8IdyxIIIffEqw5Q=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], @@ -3176,6 +3378,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -3250,6 +3454,8 @@ "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "verkit": ["verkit@0.4.0", "", {}, "sha512-sXMwN6DMHeouPfCxkxWkKAmxphWKEenHYY5H1nIBzU3PmDsmJp6kBXJdshjVdpMZuWCmL9SH7KFRx29AylpP6g=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], @@ -3266,13 +3472,17 @@ "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], @@ -3300,12 +3510,16 @@ "wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xss": ["xss@1.0.15", "", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": { "xss": "bin/xss" } }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="], "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], @@ -3336,6 +3550,12 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "yuku-ast": ["yuku-ast@0.9.4", "", { "dependencies": { "@yuku-toolchain/types": "^0.9.4" } }, "sha512-AeeHDopMAy2mMafUMEIZxmQaCJOPEvBGDSSA23y5iKuzak3H2otfmgo4ObJubuzdHlJrv4eFY3KQ5XZOru5gpw=="], + + "yuku-codegen": ["yuku-codegen@0.9.4", "", { "dependencies": { "@yuku-toolchain/types": "^0.9.4" }, "optionalDependencies": { "@yuku-codegen/binding-android-arm64": "0.9.4", "@yuku-codegen/binding-darwin-arm64": "0.9.4", "@yuku-codegen/binding-darwin-x64": "0.9.4", "@yuku-codegen/binding-freebsd-x64": "0.9.4", "@yuku-codegen/binding-linux-arm-gnu": "0.9.4", "@yuku-codegen/binding-linux-arm-musl": "0.9.4", "@yuku-codegen/binding-linux-arm64-gnu": "0.9.4", "@yuku-codegen/binding-linux-arm64-musl": "0.9.4", "@yuku-codegen/binding-linux-x64-gnu": "0.9.4", "@yuku-codegen/binding-linux-x64-musl": "0.9.4", "@yuku-codegen/binding-win32-arm64": "0.9.4", "@yuku-codegen/binding-win32-x64": "0.9.4" } }, "sha512-pPr8xA8wBjrebER5VFeqS1XqATED1dQXU7JoAectAi1RBG4YFJz5yBirnmmmHZHldgnMKs/pMGkDtwGVQX+Efg=="], + + "yuku-parser": ["yuku-parser@0.9.4", "", { "dependencies": { "@yuku-toolchain/types": "^0.9.4", "yuku-ast": "^0.9.4" }, "optionalDependencies": { "@yuku-parser/binding-android-arm64": "0.9.4", "@yuku-parser/binding-darwin-arm64": "0.9.4", "@yuku-parser/binding-darwin-x64": "0.9.4", "@yuku-parser/binding-freebsd-x64": "0.9.4", "@yuku-parser/binding-linux-arm-gnu": "0.9.4", "@yuku-parser/binding-linux-arm-musl": "0.9.4", "@yuku-parser/binding-linux-arm64-gnu": "0.9.4", "@yuku-parser/binding-linux-arm64-musl": "0.9.4", "@yuku-parser/binding-linux-x64-gnu": "0.9.4", "@yuku-parser/binding-linux-x64-musl": "0.9.4", "@yuku-parser/binding-win32-arm64": "0.9.4", "@yuku-parser/binding-win32-x64": "0.9.4" } }, "sha512-VWnoJzdB2g1JjsMEcB2MxSLoDbgISgOiNRsgiR0qrQKiUw2yyzEfYEYkNHSMNhdGkSo7gtmcN+2UOczzGDLAuw=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -3416,6 +3636,10 @@ "@buzzkit/api/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@changesets/config/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "@changesets/git/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "@cloudflare/vite-plugin/miniflare": ["miniflare@5.20260815.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260815.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ=="], "@cloudflare/vite-plugin/workerd": ["workerd@1.20260815.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260815.1", "@cloudflare/workerd-darwin-arm64": "1.20260815.1", "@cloudflare/workerd-linux-64": "1.20260815.1", "@cloudflare/workerd-linux-arm64": "1.20260815.1", "@cloudflare/workerd-windows-64": "1.20260815.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg=="], @@ -3542,6 +3766,8 @@ "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@publint/pack/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "@puppeteer/browsers/tar-fs": ["tar-fs@3.1.3", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ=="], "@puppeteer/browsers/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], @@ -3592,6 +3818,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "@tinybirdco/sdk/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "@tinybirdco/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -3628,8 +3856,12 @@ "astro/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "astro/vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], "babel-dead-code-elimination/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], @@ -3660,6 +3892,8 @@ "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], @@ -3698,12 +3932,12 @@ "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "hast-util-to-estree/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], "hast-util-to-mdast/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "ink/cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], @@ -3730,6 +3964,8 @@ "knip/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "lint-staged/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], @@ -3774,6 +4010,8 @@ "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -3784,23 +4022,19 @@ "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "pac-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], - "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "protobufjs/@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - "proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "protobufjs/@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], @@ -3824,8 +4058,12 @@ "retext-smartypants/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -3848,8 +4086,6 @@ "socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "style-to-js/style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -3872,6 +4108,8 @@ "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "tsdown/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -3890,6 +4128,8 @@ "vite/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "vitest/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -3904,6 +4144,8 @@ "@astrojs/react/vite/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "@astrojs/react/vite/rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], + "@babel/core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], @@ -4192,6 +4434,8 @@ "@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "@stoplight/spectral-runtime/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "@tinybirdco/sdk/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@tinybirdco/sdk/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -4368,6 +4612,10 @@ "astro/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + "astro/vite/rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], + + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "babel-dead-code-elimination/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "babel-dead-code-elimination/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], @@ -4482,6 +4730,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "hast-util-to-mdast/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], "ink/cli-cursor/restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], @@ -4550,6 +4800,10 @@ "miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "public-ip/got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="], "public-ip/got/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], @@ -4708,6 +4962,38 @@ "@astrojs/react/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "@astrojs/react/vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], + + "@astrojs/react/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], + + "@astrojs/react/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@babel/plugin-transform-typescript/@babel/helper-create-class-features-plugin/@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], "@babel/plugin-transform-typescript/@babel/helper-create-class-features-plugin/@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], @@ -4830,6 +5116,10 @@ "@stoplight/spectral-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@stoplight/spectral-runtime/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "@stoplight/spectral-runtime/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "astro/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], "astro/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], @@ -4852,6 +5142,38 @@ "astro/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "astro/vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], + + "astro/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], + + "astro/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], + + "astro/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], + + "astro/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], + + "astro/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], + + "astro/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], + + "astro/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], + + "astro/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], + + "astro/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], + + "astro/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], + + "astro/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], + + "astro/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], + + "astro/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], + + "astro/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], + + "astro/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "favicons/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 5ff8b40f..ef29d048 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,16 @@ "format:fix": "biome check . --write", "prepare": "husky", "check-types": "turbo run check-types && tsc -p scripts", - "test": "turbo run test --filter=buzzkit --filter=@buzzkit/auth --filter=@buzzkit/schema --filter=@buzzkit/web --filter=@buzzkit/marketing" + "test": "turbo run test --filter=buzzkit --filter=@buzzkit/auth --filter=@buzzkit/schema --filter=@buzzkit/web --filter=@buzzkit/marketing", + "changeset": "changeset", + "sdk:build": "turbo run build --filter=buzzkit", + "sdk:release": "bun run --cwd packages/buzzkit scripts/publish-manifest.ts && changeset publish" }, "devDependencies": { "@babel/parser": "^8.0.4", "@biomejs/biome": "2.5.9", + "@changesets/changelog-github": "^1.0.1", + "@changesets/cli": "^3.0.2", "@types/node": "^26.4.0", "husky": "^9.1.7", "lint-staged": "^17.2.0", diff --git a/packages/buzzkit/.gitignore b/packages/buzzkit/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/packages/buzzkit/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/buzzkit/CLAUDE.md b/packages/buzzkit/CLAUDE.md index 5c2b68c7..1ff6a697 100644 --- a/packages/buzzkit/CLAUDE.md +++ b/packages/buzzkit/CLAUDE.md @@ -1,12 +1,66 @@ # buzzkit — the framework package -The public `buzzkit` package: the server SDK customers install, and what the platform (`apps/api`) dogfoods. One package, organized by subpath exports (`buzzkit/webhooks` and `buzzkit/expressions` today; the send client, subscriber and event APIs as they land), never split into separate npm packages for organization's sake. +The public `buzzkit` package: the SDK customers install, and what the platform (`apps/api`) dogfoods. **One package, seven entry points**, never split into separate npm packages for organization's sake. -**Only what runs in a customer's backend belongs here.** Types and authoring-time validation of things a customer writes (an inline segment expression on a send: `Expression`, `lintExpression`, `isExpression`) yes; anything that only runs on our side (TypeBox request schemas, expression evaluation, the segment compiler, template rendering, cron and zone arithmetic) no, that lives in `apps/api`. Workflows are never defined from code, so nothing about them is here: their language is the private `@buzzkit/schema/workflows` package. The expression lint takes `checkers` so that package can add its run-only conditions without them leaking into the SDK. +| Entry | Runs | Key | Reaches | +|---|---|---|---| +| `buzzkit` | server | `bk_ws_` / `bk_tn_` | `/v1/*` | +| `buzzkit/client` | browser | `bk_pk_` | `/v1/client/*` | +| `buzzkit/react` | browser | — | hooks over `buzzkit/client` | +| `buzzkit/webhooks` | server | — | signing and verification | +| `buzzkit/expressions` | either | — | the segment grammar | +| `buzzkit/workflows` | either | — | the workflow spec grammar | +| `buzzkit/sources` | either | — | the source mapping grammar | + +One package rather than Stripe's three, because `Topic`, `SubscriberPreference`, `Subscription` and `Channel` are shared between the halves and duplicating them across packages is exactly the drift the parity test exists to prevent — and because version skew between a client and a server SDK is a recurring pain. (Stripe's split is driven by PCI: `@stripe/stripe-js` loads a remotely-hosted script. That constraint does not apply here.) `sideEffects: false` plus subpath `exports` keeps the server tree out of a browser bundle; React is an **optional** peer dependency, so a server-only install never pulls it. + +## Layout + +**`buzzkit` is where every type and vocabulary in the contract is defined**, and the platform imports them rather than restating them. `resources/common.ts` holds the shared vocabularies as `as const` arrays (`CHANNELS`, `MEMBER_ROLES`, `DELIVERY_STATUSES`, …) with the unions derived from them, and `packages/database` builds its `pgEnum`s from those same arrays, so a Postgres enum and its TypeScript union can never disagree. **A vocabulary belongs here only if the SDK's own surface uses it** — `api_key_kind`, `subscriber_alias_source` and `live_activity_kind` have exactly one consumer (the schema), so they are declared there and are not duplicates. The same test applies to types: `*Resource` types stay module-exported for the scope classes but are kept out of the `BuzzKit` namespace, which is the contract you send and receive, not the plumbing. `test/resources/common.test.ts` pins the Postgres-backed values, because changing one is a migration, not an edit. The rest: `@buzzkit/schema` re-exports the workflow and source grammars from here and keeps only the behavior (lint, parse, evaluate, presets); `packages/database` types its `expression` and `spec` jsonb columns with them; `apps/api` is held to the wire types by the parity assertions. The dependency runs one way — `buzzkit` ← `@buzzkit/schema` ← `apps/*` — so nothing in here may import a workspace package. + +`src/core/` is shared by both halves — `transport.ts` (one request path: headers, query, envelope unwrapping, the retry loop), `errors.ts`, `retry.ts`, `pagination.ts`, `config.ts`, `keys.ts`. The transport is auth-agnostic: it holds static headers and `with()` returns a re-scoped copy, so each half decides its own auth. `src/server/`, `src/client/` and `src/react/` are the three entries, and `src/resources/.ts` holds one file per API resource — its types and its `Resource(transport)` factory — shared by whichever entry needs them. + +## The client + +`new BuzzKit({ apiKey })` over plain `fetch` — **hand-written, not generated**: the package cannot depend on `@buzzkit/api` (the platform depends on `buzzkit`, so it would be a cycle) and the OpenAPI carries no response schemas, so entity types are written here and held to the API by a type-level parity test. + +- **Subpath = runtime behavior with its own call site; namespace = types.** There is no `buzzkit/types`, because grouping by kind rather than concern is what the conventions warn against, and types are erased anyway. +- **Types live in a `BuzzKit` namespace**, Stripe's shape: `class BuzzKit` and `namespace BuzzKit` are declared together in `server/buzzkit.ts` (declaration merging only works in one file; the merge survives the re-export from `src/index.ts`) and the namespace aliases every resource type through `import type * as R`. So it is `BuzzKit.Message`, not a flat `Message` — ~155 collision-prone names like `Source`, `Delivery` and `Secret` stay out of the root. Error classes stay flat, because `instanceof BuzzKitError` is how they are used. +- **Scopes mirror the key kinds.** Tenant resources hang off the root (a workspace key resolves to its default tenant, a tenant key is locked to one) and off `buzzkit.tenant(slug)`, which sets `buzzkit-tenant`. Workspace resources need the slug in the path, so they live behind `buzzkit.workspace(slug)`. +- **`SubscriberScope` is the one-person handle** (`client/subscriber.ts`): `buzzkit.subscriber(externalId)` builds it with no request, `await buzzkit.identify(externalId, …)` upserts first and returns one whose `data` is the record (the `TData` parameter is how `.data` is non-null there and nullable otherwise). It binds the external id into `send`, `track(name, data?)`, `subscribe`, preferences, timeline, deliveries and runs, so `to` / `externalId` can never be passed twice or disagree. `buzzkit.subscribers.*` stays the flat resource for lists and one-off writes. +- **Server-side, `identify` is an upsert**, not the analytics call: it is how a customer's backend keeps profiles in sync so segments and templates have attributes to read. It is `PUT /v1/subscribers/:externalId`, never the client route. +- **Only what an API key can call.** Session-only scopes (`account:*`, `keys:*`, `invites:*`, `members:write`, `workspace:delete`, `tenants:secrets`) and `/v1/client/*` are deliberately absent — an SDK method that always 403s is a broken promise. `libs/scopes.ts` is the source of truth. +- **The key kinds are enforced, not documented.** `buzzkit` throws on a `bk_pk_` key and `buzzkit/client` throws on a `bk_ws_` / `bk_tn_` one (`core/keys.ts`), so importing the wrong entry fails in development instead of leaking a full-tenant credential into a browser bundle. Next.js route handlers, server actions and RSC take `buzzkit`; client components take `buzzkit/react`. +- **The identity handshake spans both halves.** `/v1/client/*` authenticates with `BuzzKit-Subscriber` + `BuzzKit-Identity`, where the hash is HMAC-SHA256 of the external id under the tenant identity secret — so it must be minted on a server. `signIdentity(externalId, secret)` in the server entry does that, and `apps/api/test/packages/buzzkit/contract.test.ts` asserts it equals the API's own `computeIdentityHash`. The secret is not fetchable with an API key (`tenants:secrets` is session-only), so a customer reads it from the dashboard into an env var. +- **Method names:** `list` / `retrieve` / `create` / `update` / `remove` plus the domain verbs (`send`, `cancel`, `publish`, `pause`, `rotate`, `replay`, `validate`, `preview`, `track`, `upsert`). Never `get*` or `delete*`. +- Every list returns a `PagePromise`: `await` it for one `{ items, hasMore, nextCursor, total? }` page, or `for await` it to walk every page. +- Retries cover connection failures, timeouts, 429 and 5xx, with jitter and `Retry-After`, and only for requests that are safe to repeat — GET/PUT/DELETE, or a POST carrying an idempotency key. `messages.send` generates one when the caller does not. +- Workflow specs and source mappings are **fully typed** (`BuzzKit.WorkflowSpec`, `BuzzKit.SourceMapping`) — their grammars live in `src/workflows/` and `src/sources/`, which is also what `@buzzkit/schema` and `packages/database` consume. Those two subpaths carry runtime vocabularies (`STEP_KINDS`, `SOURCE_PROVIDERS`), which is why they are subpaths rather than namespace-only. + +## The browser and React entries + +`buzzkit/client` is `BuzzKitClient`, holding a publishable key and an optional `{ externalId, identityHash }` identity (`as()` returns a re-identified copy; every call refuses without one). `buzzkit/react` is `BuzzKitProvider` plus `useBuzzKit`, `useIdentity`, `usePreferences`, `useIdentify` and `useTrack`, written with `createElement` so the whole package stays `.ts` and needs no JSX build step. + +**What the browser can actually do is narrower than it looks, and the shape follows the API, not wishful thinking.** There is no web push channel — `subscription_platform` is `ios | android` and `resolveSubscriptionInput` rejects a push registration without one — so `buzzkit/client` registers **email** subscriptions only. There is also no `GET /v1/client/subscriptions`, so there is no device-listing hook. What is real: identify, topic preferences (including the push preferences that govern their phone), email subscribe, and web events. Add hooks when the endpoints exist, not before. + +**Parity is enforced, not assumed.** `apps/api/test/packages/buzzkit/parity.ts` asserts every SDK entity against the API's own serializers, modelling what the envelope does on the wire (`Date` → ISO string, `id`/`*Id` → sqid). It is type-level, so `bun check-types` and the pre-push hook fail on drift; add an assertion whenever you add an entity. + +**A definition belongs here exactly when a customer can observe it through the public API.** Wire vocabularies, entity and parameter types, and the grammars a customer writes (`Expression`, `WorkflowSpec`, `SourceMapping`) yes; anything that only runs on our side (TypeBox request schemas, expression evaluation, the segment compiler, template rendering, cron and zone arithmetic, actor and queue shapes) no, that lives in `apps/api`. That test is what decides the edges: `subscriber_alias_source` is in here because the API serializes it, while `api_key_kind` and `live_activity_kind` stay in `packages/database` because key management is session-only and `kind` is storage where the wire carries `event`. The expression lint takes `checkers` so `@buzzkit/schema` can add its run-only conditions without them leaking into the SDK. + +## Releasing + +This is the only package published to npm, and it is published from `main` by `.github/workflows/release.yml` through npm's GitHub trusted publisher, so there is no npm token anywhere. + +**Every change that a customer would notice needs a changeset.** Run `bun run changeset`, pick the bump, write one sentence in the voice of the changelog, and commit that file with your work. Changesets is configured to ignore every other package, so app and dashboard work never versions the SDK. A change nobody installs cares about takes `bun run changeset --empty`. + +The rest is automatic. When a changeset lands on `main` the workflow builds, runs the SDK suite with its coverage thresholds, runs publint, then packs the package and imports every entry point from a clean project — the guard that stopped a release shipping raw TypeScript that Node cannot load from `node_modules`. It then opens or updates a pull request titled "chore: release the SDK", which consumes the changesets, bumps the version and writes `CHANGELOG.md`. Merging that pull request publishes and tags. Nothing reaches npm without that merge. + +**The workspace resolves `src`, npm gets `dist`.** `exports` points at TypeScript so the monorepo type-checks against real source and the parity assertions compare hand-written types rather than bundled declarations. `scripts/publish-manifest.ts` rewrites those entries to the built `.mjs` and `.d.mts` at publish time, and `tsdown.config.ts` builds them. Never point `exports` at `dist` to make something resolve — build it instead. ## Rules - **Same code standards as the API** (`apps/api/CLAUDE.md`): no comments anywhere, names written out, the verb vocabulary, one concern per file, ordered types → constants → errors → pure helpers → the public functions. A subpath is a directory with an `index.ts` barrel that exports only its public surface; internals stay unexported. - **Runtime-neutral.** Web platform APIs only (`crypto.subtle`, `TextEncoder`, `fetch`, `Headers`): the same file must run in a Worker, Node 22+, Bun and a browser. No `node:*` imports in `src/`. Typed arrays are allocated so they are `Uint8Array` (WebCrypto's `BufferSource` refuses `ArrayBufferLike`). -- **Tests mirror `src/`** in `test/` (`test/webhooks/signature.test.ts` ↔ `src/webhooks/signature.ts`), vitest in the plain Node pool, no mocks of the platform; `bun run test` here, `bun run check-types` for types. Node's `crypto.createHmac` is allowed in tests as an independent oracle. +- **Tests mirror `src/`** in `test/` (`test/core/transport.test.ts` ↔ `src/core/transport.ts`). Two vitest projects: `node` for everything and `jsdom` for `test/react/**` — the environment is set in `vitest.config.ts` rather than a `@vitest-environment` docblock, because the comment ban forbids one. The React suite is the package's only `.tsx`: JSX reads better there and is the way around `noChildrenProp`, which fires on a `children` key even inside `createElement`. Everything goes through `test/utils/stub.ts`, a recording `fetch` that honors `AbortSignal` so the timeout path is really exercised — no platform mocks. `bun run test`, `bun run test:coverage` (thresholds sit just under the current numbers, so a regression fails), `bun run check-types`. Node's `crypto.createHmac` is the independent oracle for `signIdentity`. +- **The parity suite lives in the API**, not here (`apps/api/test/packages/buzzkit/`): it needs `@buzzkit/api` types, and a dev dependency on them from this package would be a workspace cycle. - **Every export is API surface.** Adding one means docs (`docs/`) and a test; renaming one is a breaking change once the package is published. diff --git a/packages/buzzkit/README.md b/packages/buzzkit/README.md new file mode 100644 index 00000000..b71b1477 --- /dev/null +++ b/packages/buzzkit/README.md @@ -0,0 +1,182 @@ + +
+ + BuzzKit + + +

BuzzKit SDK

+ The TypeScript SDK for BuzzKit, the open source notification orchestration layer +
+ + +

+ Read the docs » +
+
+ Installation + · + Send + · + Subscribers + · + Browser and React + · + Webhooks + · + Entry points +

+ +## Installation + +```sh +npm install buzzkit +``` + +Node 22 or newer, and it runs unchanged in Bun, Deno, Cloudflare Workers and the browser. + +## Send + +Create a client with a workspace or tenant key from the dashboard. Every call is typed against the API, so an unknown field or a wrong status is a compile error, not a runtime surprise. + +```ts +import { BuzzKit } from 'buzzkit'; + +const buzzkit = new BuzzKit({ apiKey: process.env.BUZZKIT_API_KEY }); + +await buzzkit.messages.send({ + to: 'user_42', + title: 'Your order shipped', + body: 'Arrives Thursday', +}); +``` + +Send to a topic, a saved segment, or an inline expression: + +```ts +await buzzkit.messages.send({ topic: 'product-updates', title: 'New in BuzzKit' }); +await buzzkit.messages.send({ segment: 'power-users', title: 'Early access' }); +await buzzkit.messages.send({ + where: { attribute: 'plan', eq: 'pro' }, + title: 'Your plan changed', +}); +``` + +Sends are idempotent. The SDK attaches an idempotency key when you do not, so a retry after a timeout never sends twice. + +## Subscribers + +`identify` keeps a person's profile in sync so segments and templates have something to read. It is an upsert, safe to call on every login. + +```ts +await buzzkit.identify('user_42', { + email: 'ada@example.com', + attributes: { plan: 'pro', seats: 12 }, +}); +``` + +A subscriber handle binds the id once, so it can never be passed twice or disagree: + +```ts +const user = buzzkit.subscriber('user_42'); + +await user.track('cart.abandoned', { value: 79 }); +await user.send({ title: 'Still there?' }); + +const preferences = await user.preferences(); +const timeline = await user.timeline({ limit: 50 }); +``` + +Every list is a page you can await or walk: + +```ts +const first = await buzzkit.subscribers.list({ search: 'ada' }); + +for await (const page of buzzkit.subscribers.list()) { + console.log(page.items.length); +} +``` + +## Browser and React + +`buzzkit/client` holds a publishable key and never a secret one. It refuses a server key, so a full-tenant credential cannot reach a browser bundle by mistake. + +```ts +import { BuzzKitClient } from 'buzzkit/client'; + +const client = new BuzzKitClient({ + publishableKey: 'bk_pk_...', + identity: { externalId: 'user_42', identityHash }, +}); +``` + +The identity hash is minted on your server, never in the browser: + +```ts +import { signIdentity } from 'buzzkit'; + +const identityHash = await signIdentity('user_42', process.env.BUZZKIT_IDENTITY_SECRET); +``` + +`buzzkit/react` wraps the same client: + +```tsx +import { BuzzKitProvider, usePreferences } from 'buzzkit/react'; + +function Settings() { + const { data, update, isLoading } = usePreferences(); + + if (isLoading || !data) return null; + + return data.map((topic) => ( + update({ [topic.slug]: { push: optedIn } })} + /> + )); +} +``` + +## Webhooks + +Verify a delivery before you trust it. The raw body must be passed exactly as received. + +```ts +import { verifyWebhook } from 'buzzkit/webhooks'; + +const event = await verifyWebhook(rawBody, request.headers, process.env.BUZZKIT_WEBHOOK_SECRET); +``` + +## Retries + +Retries cover connection failures, timeouts, 429 and 5xx, and only for requests that are safe to repeat: GET, PUT, DELETE, or a POST carrying an idempotency key, which `messages.send` generates for you. + +A `Retry-After` header is honored exactly rather than shortened to the backoff ceiling. When a server asks for longer than `maxRetryAfterMs`, one minute by default, the SDK stops instead of retrying early and throws, with `retryAfterSeconds` on the error so you can queue the work rather than block a request on it. Raise `maxRetryAfterMs` where waiting is cheap, such as a background job. + +```ts +try { + await buzzkit.messages.send({ to: 'user_42', title: 'Hello' }); +} catch (error) { + if (error instanceof BuzzKitError && error.retryAfterSeconds) { + await scheduleForLater(error.retryAfterSeconds); + } +} +``` + +## Entry points + +| Entry | Runs | Holds | +| --- | --- | --- | +| `buzzkit` | server | The API client, identity signing, the shared vocabularies | +| `buzzkit/client` | browser | The publishable-key client | +| `buzzkit/react` | browser | `BuzzKitProvider` and the hooks | +| `buzzkit/webhooks` | server | Signing and verification | +| `buzzkit/expressions` | either | The segment expression grammar | +| `buzzkit/workflows` | either | The workflow spec grammar | +| `buzzkit/sources` | either | The source mapping grammar | + +React is an optional peer dependency, so a server-only install never pulls it. + +## License + +MIT. The BuzzKit core is licensed under the [GNU Affero General Public License Version 3](https://github.com/buzzkit-dev/buzzkit/blob/main/LICENSE). diff --git a/packages/buzzkit/package.json b/packages/buzzkit/package.json index b85021a6..cb0e487c 100644 --- a/packages/buzzkit/package.json +++ b/packages/buzzkit/package.json @@ -3,15 +3,25 @@ "license": "MIT", "version": "0.0.0", "type": "module", - "private": true, + "sideEffects": false, "files": [ - "src" + "dist", + "README.md", + "LICENSE" ], "exports": { ".": { "types": "./src/index.ts", "default": "./src/index.ts" }, + "./client": { + "types": "./src/client/index.ts", + "default": "./src/client/index.ts" + }, + "./react": { + "types": "./src/react/index.ts", + "default": "./src/react/index.ts" + }, "./webhooks": { "types": "./src/webhooks/index.ts", "default": "./src/webhooks/index.ts" @@ -19,16 +29,66 @@ "./expressions": { "types": "./src/expressions/index.ts", "default": "./src/expressions/index.ts" + }, + "./workflows": { + "types": "./src/workflows/index.ts", + "default": "./src/workflows/index.ts" + }, + "./sources": { + "types": "./src/sources/index.ts", + "default": "./src/sources/index.ts" + } + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true } }, "scripts": { + "build": "tsdown", "check-types": "tsc --noEmit", - "test": "vitest run", + "test": "vitest run --coverage", "prepublishOnly": "publint" }, "devDependencies": { + "@testing-library/react": "^16", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^4.1.11", + "jsdom": "^27", "publint": "^0.3.24", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsdown": "^0.23.0", "typescript": "^7.0.2", "vitest": "~4.1.10" + }, + "description": "The TypeScript SDK for BuzzKit, the open source notification orchestration layer.", + "keywords": [ + "push notifications", + "apns", + "fcm", + "notifications", + "ios", + "workflows", + "segments", + "buzzkit" + ], + "homepage": "https://buzzkit.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/buzzkit-dev/buzzkit.git", + "directory": "packages/buzzkit" + }, + "bugs": { + "url": "https://github.com/buzzkit-dev/buzzkit/issues" + }, + "author": "Christo Todorov", + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/buzzkit/scripts/publish-manifest.ts b/packages/buzzkit/scripts/publish-manifest.ts new file mode 100644 index 00000000..e0a1fd32 --- /dev/null +++ b/packages/buzzkit/scripts/publish-manifest.ts @@ -0,0 +1,23 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +type Manifest = { + exports: Record; + files: string[]; +}; + +const MANIFEST_PATH = new URL('../package.json', import.meta.url); + +function published(entry: { types: string }): { types: string; default: string } { + const built = entry.types.replace(/^\.\/src\//, './dist/').replace(/\.ts$/, ''); + return { types: `${built}.d.mts`, default: `${built}.mjs` }; +} + +const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Manifest; + +manifest.exports = Object.fromEntries( + Object.entries(manifest.exports).map(([name, entry]) => [name, published(entry)]) +); +manifest.files = ['dist']; + +writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`); +process.stdout.write(`rewrote ${Object.keys(manifest.exports).length} entry points to the built output\n`); diff --git a/packages/buzzkit/src/client/buzzkit.ts b/packages/buzzkit/src/client/buzzkit.ts new file mode 100644 index 00000000..2dfcf32c --- /dev/null +++ b/packages/buzzkit/src/client/buzzkit.ts @@ -0,0 +1,142 @@ +import { ConfigurationError } from '../core/errors'; +import type { Page } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Attributes, Deleted } from '../resources/common'; +import type { TrackedEvent } from '../resources/events'; +import type { Subscriber } from '../resources/subscribers'; +import type { Subscription } from '../resources/subscriptions'; +import type { SubscriberPreference } from '../resources/topics'; +import type { BrowserOptions, Identity, ResolvedBrowserOptions } from './options'; +import { browserTransport, resolveBrowserOptions } from './options'; + +export type IdentifyParams = { + attributes?: Attributes; + email?: string; + subscribe?: { email?: boolean }; + anonymousId?: string; +}; + +export type PreferenceChanges = Record>>; + +export type SubscribeEmailParams = { + address: string; +}; + +export class BuzzKitClient { + private readonly options: ResolvedBrowserOptions; + private readonly transport: Transport; + + constructor(options: BrowserOptions) { + this.options = resolveBrowserOptions(options); + this.transport = browserTransport(this.options); + } + + get identity(): Identity | null { + return this.options.identity; + } + + as(identity: Identity): BuzzKitClient { + return new BuzzKitClient({ ...this.options, identity }); + } + + async identify(params: IdentifyParams = {}): Promise { + const identity = this.requireIdentity(); + + return await this.transport.request({ + method: 'POST', + path: '/v1/client/identify', + body: { ...params, externalId: identity.externalId, identityHash: identity.identityHash }, + }); + } + + async track(name: string, data?: Record): Promise { + const identity = this.requireIdentity(); + + const page = await this.transport.request>({ + method: 'POST', + path: '/v1/client/events', + body: { + externalId: identity.externalId, + identityHash: identity.identityHash, + source: 'web', + events: [{ name, data }], + }, + }); + + const [tracked] = page.items; + if (!tracked) { + throw new ConfigurationError(`The BuzzKit API accepted no event for '${name}'`); + } + + return tracked; + } + + async preferences(): Promise { + this.requireIdentity(); + + const page = await this.transport.request>({ + method: 'GET', + path: '/v1/client/preferences', + }); + + return page.items; + } + + async updatePreferences(preferences: PreferenceChanges): Promise { + this.requireIdentity(); + + const page = await this.transport.request>({ + method: 'PATCH', + path: '/v1/client/preferences', + body: { preferences }, + }); + + return page.items; + } + + async subscribeEmail(params: SubscribeEmailParams): Promise { + const identity = this.requireIdentity(); + + return await this.transport.request({ + method: 'POST', + path: '/v1/client/subscriptions', + body: { + externalId: identity.externalId, + identityHash: identity.identityHash, + channel: 'email', + address: params.address, + }, + }); + } + + async updateSubscription(id: string, enabled: boolean): Promise { + this.requireIdentity(); + + return await this.transport.request({ + method: 'PATCH', + path: `/v1/client/subscriptions/${encodeSegment(id)}`, + body: { enabled }, + }); + } + + async removeSubscription(id: string): Promise> { + this.requireIdentity(); + + return await this.transport.request({ + method: 'DELETE', + path: `/v1/client/subscriptions/${encodeSegment(id)}`, + }); + } + + private requireIdentity(): Identity { + const { identity } = this.options; + if (!identity) { + throw new ConfigurationError( + 'No subscriber identity — pass { identity } to the BuzzKit client, or call client.as({ externalId })' + ); + } + + return identity; + } +} diff --git a/packages/buzzkit/src/client/index.ts b/packages/buzzkit/src/client/index.ts new file mode 100644 index 00000000..a19724e3 --- /dev/null +++ b/packages/buzzkit/src/client/index.ts @@ -0,0 +1,29 @@ +export { + AuthenticationError, + BadRequestError, + BuzzKitError, + ConfigurationError, + ConflictError, + ConnectionError, + type ErrorBody, + isBuzzKitError, + NotFoundError, + PermissionError, + RateLimitError, + ServerError, + TimeoutError, +} from '../core/errors'; +export type { Channel } from '../resources/common'; +export type { Subscriber } from '../resources/subscribers'; +export type { Subscription } from '../resources/subscriptions'; +export type { ChannelPreference, SubscriberPreference } from '../resources/topics'; +export { + BuzzKitClient, + type IdentifyParams, + type PreferenceChanges, + type SubscribeEmailParams, +} from './buzzkit'; +export type { + BrowserOptions, + Identity, +} from './options'; diff --git a/packages/buzzkit/src/client/options.ts b/packages/buzzkit/src/client/options.ts new file mode 100644 index 00000000..3c90a679 --- /dev/null +++ b/packages/buzzkit/src/client/options.ts @@ -0,0 +1,74 @@ +import { DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS, resolveBaseUrl, resolveFetch } from '../core/config'; +import { ConfigurationError } from '../core/errors'; +import { assertClientKey } from '../core/keys'; +import { Transport } from '../core/transport'; + +export type Identity = { + externalId: string; + identityHash?: string; +}; + +export type BrowserOptions = { + publishableKey: string; + identity?: Identity; + baseUrl?: string; + timeoutMs?: number; + maxRetries?: number; + maxRetryAfterMs?: number; + headers?: Record; + fetch?: typeof globalThis.fetch; +}; + +export type ResolvedBrowserOptions = { + publishableKey: string; + identity: Identity | null; + baseUrl: string; + timeoutMs: number; + maxRetries: number; + maxRetryAfterMs?: number; + headers: Record; + fetch: typeof globalThis.fetch; +}; + +export function resolveBrowserOptions(options: BrowserOptions): ResolvedBrowserOptions { + if (!options.publishableKey) { + throw new ConfigurationError('Missing publishable key — pass { publishableKey } to the BuzzKit client'); + } + + assertClientKey(options.publishableKey); + + return { + publishableKey: options.publishableKey, + identity: options.identity ?? null, + baseUrl: resolveBaseUrl(options.baseUrl), + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, + maxRetryAfterMs: options.maxRetryAfterMs, + headers: options.headers ?? {}, + fetch: resolveFetch(options.fetch), + }; +} + +function identityHeaders(identity: Identity | null): Record { + if (!identity) return {}; + + return { + 'buzzkit-subscriber': identity.externalId, + ...(identity.identityHash ? { 'buzzkit-identity': identity.identityHash } : {}), + }; +} + +export function browserTransport(options: ResolvedBrowserOptions): Transport { + return new Transport({ + baseUrl: options.baseUrl, + timeoutMs: options.timeoutMs, + maxRetries: options.maxRetries, + maxRetryAfterMs: options.maxRetryAfterMs, + fetch: options.fetch, + headers: { + ...options.headers, + authorization: `Bearer ${options.publishableKey}`, + ...identityHeaders(options.identity), + }, + }); +} diff --git a/packages/buzzkit/src/core/config.ts b/packages/buzzkit/src/core/config.ts new file mode 100644 index 00000000..4f79a7d4 --- /dev/null +++ b/packages/buzzkit/src/core/config.ts @@ -0,0 +1,28 @@ +import { ConfigurationError } from './errors'; + +export const DEFAULT_BASE_URL = 'https://api.buzzkit.dev'; + +export const DEFAULT_TIMEOUT_MS = 30_000; + +export const DEFAULT_MAX_RETRIES = 2; + +export const API_KEY_VARIABLE = 'BUZZKIT_API_KEY'; + +export function readEnvironment(name: string): string | undefined { + const host = globalThis as { process?: { env?: Record } }; + return host.process?.env?.[name]; +} + +export function resolveBaseUrl(baseUrl: string | undefined): string { + const resolved = baseUrl ?? readEnvironment('BUZZKIT_BASE_URL') ?? DEFAULT_BASE_URL; + return resolved.endsWith('/') ? resolved.slice(0, -1) : resolved; +} + +export function resolveFetch(fetcher: typeof globalThis.fetch | undefined): typeof globalThis.fetch { + const resolved = fetcher ?? globalThis.fetch; + if (typeof resolved !== 'function') { + throw new ConfigurationError('No fetch implementation available — pass { fetch } to the BuzzKit client'); + } + + return resolved.bind(globalThis); +} diff --git a/packages/buzzkit/src/core/errors.ts b/packages/buzzkit/src/core/errors.ts new file mode 100644 index 00000000..016cdfc5 --- /dev/null +++ b/packages/buzzkit/src/core/errors.ts @@ -0,0 +1,135 @@ +export type ErrorBody = { + code: string; + message: string; + param?: string; + details?: unknown; +}; + +export type ErrorContext = { + status: number | null; + code: string; + param?: string; + details?: unknown; + requestId?: string; + retryAfterSeconds?: number; +}; + +export class BuzzKitError extends Error { + readonly status: number | null; + readonly code: string; + readonly param?: string; + readonly details?: unknown; + readonly requestId?: string; + readonly retryAfterSeconds?: number; + + constructor(message: string, context: ErrorContext) { + super(message); + this.name = 'BuzzKitError'; + this.status = context.status; + this.code = context.code; + this.param = context.param; + this.details = context.details; + this.requestId = context.requestId; + this.retryAfterSeconds = context.retryAfterSeconds; + } +} + +export class BadRequestError extends BuzzKitError { + constructor(message: string, context: ErrorContext) { + super(message, context); + this.name = 'BadRequestError'; + } +} + +export class AuthenticationError extends BuzzKitError { + constructor(message: string, context: ErrorContext) { + super(message, context); + this.name = 'AuthenticationError'; + } +} + +export class PermissionError extends BuzzKitError { + constructor(message: string, context: ErrorContext) { + super(message, context); + this.name = 'PermissionError'; + } +} + +export class NotFoundError extends BuzzKitError { + constructor(message: string, context: ErrorContext) { + super(message, context); + this.name = 'NotFoundError'; + } +} + +export class ConflictError extends BuzzKitError { + constructor(message: string, context: ErrorContext) { + super(message, context); + this.name = 'ConflictError'; + } +} + +export class RateLimitError extends BuzzKitError { + constructor(message: string, context: ErrorContext) { + super(message, context); + this.name = 'RateLimitError'; + } +} + +export class ServerError extends BuzzKitError { + constructor(message: string, context: ErrorContext) { + super(message, context); + this.name = 'ServerError'; + } +} + +export class ConnectionError extends BuzzKitError { + constructor(message: string, options: { cause?: unknown; code?: string } = {}) { + super(message, { status: null, code: options.code ?? 'connection', details: options.cause }); + this.name = 'ConnectionError'; + this.cause = options.cause; + } +} + +export class TimeoutError extends ConnectionError { + constructor(message: string, cause?: unknown) { + super(message, { cause, code: 'timeout' }); + this.name = 'TimeoutError'; + } +} + +export class ConfigurationError extends BuzzKitError { + constructor(message: string) { + super(message, { status: null, code: 'configuration' }); + this.name = 'ConfigurationError'; + } +} + +export function isBuzzKitError(value: unknown): value is BuzzKitError { + return value instanceof BuzzKitError; +} + +export function resolveError( + status: number, + body: ErrorBody, + meta: { requestId?: string; retryAfterSeconds?: number } +): BuzzKitError { + const context: ErrorContext = { + status, + code: body.code, + param: body.param, + details: body.details, + requestId: meta.requestId, + retryAfterSeconds: meta.retryAfterSeconds, + }; + + if (status === 400 || status === 422) return new BadRequestError(body.message, context); + if (status === 401) return new AuthenticationError(body.message, context); + if (status === 403) return new PermissionError(body.message, context); + if (status === 404) return new NotFoundError(body.message, context); + if (status === 409 || status === 410) return new ConflictError(body.message, context); + if (status === 429) return new RateLimitError(body.message, context); + if (status >= 500) return new ServerError(body.message, context); + + return new BuzzKitError(body.message, context); +} diff --git a/packages/buzzkit/src/core/keys.ts b/packages/buzzkit/src/core/keys.ts new file mode 100644 index 00000000..afcef86f --- /dev/null +++ b/packages/buzzkit/src/core/keys.ts @@ -0,0 +1,32 @@ +import type { KeyKind } from '../resources/common'; +import { ConfigurationError } from './errors'; + +const KEY_PREFIXES: Record = { + bk_ws_: 'workspace', + bk_tn_: 'tenant', + bk_pk_: 'client', +}; + +function resolveKeyKind(apiKey: string): KeyKind | null { + for (const [prefix, kind] of Object.entries(KEY_PREFIXES)) { + if (apiKey.startsWith(prefix)) return kind; + } + return null; +} + +export function assertServerKey(apiKey: string): void { + if (resolveKeyKind(apiKey) !== 'client') return; + + throw new ConfigurationError( + 'A client key (bk_pk_) cannot be used with the server client — it only reaches /v1/client/*. Use a workspace (bk_ws_) or tenant (bk_tn_) key here, or import buzzkit/client.' + ); +} + +export function assertClientKey(apiKey: string): void { + const kind = resolveKeyKind(apiKey); + if (kind !== 'workspace' && kind !== 'tenant') return; + + throw new ConfigurationError( + 'A workspace or tenant key grants access to the whole tenant and must never reach a browser. Use a client key (bk_pk_) with buzzkit/client.' + ); +} diff --git a/packages/buzzkit/src/core/pagination.ts b/packages/buzzkit/src/core/pagination.ts new file mode 100644 index 00000000..03a799c0 --- /dev/null +++ b/packages/buzzkit/src/core/pagination.ts @@ -0,0 +1,40 @@ +export type Page = { + items: T[]; + hasMore: boolean; + nextCursor: string | null; + total?: number; +}; + +export type PageParams = { + limit?: number; + cursor?: string; +}; + +export type PagePromise = Promise> & AsyncIterable; + +export function paginate( + load: (params: TParams) => Promise>, + params: TParams +): PagePromise { + let pending: Promise> | undefined; + const first = () => { + pending ??= load(params); + return pending; + }; + + return { + then: (onFulfilled, onRejected) => first().then(onFulfilled, onRejected), + catch: (onRejected) => first().catch(onRejected), + finally: (onFinally) => first().finally(onFinally), + [Symbol.toStringTag]: 'PagePromise', + async *[Symbol.asyncIterator]() { + let page = await first(); + yield* page.items; + + while (page.hasMore && page.nextCursor !== null) { + page = await load({ ...params, cursor: page.nextCursor }); + yield* page.items; + } + }, + }; +} diff --git a/packages/buzzkit/src/core/retry.ts b/packages/buzzkit/src/core/retry.ts new file mode 100644 index 00000000..e60c24cb --- /dev/null +++ b/packages/buzzkit/src/core/retry.ts @@ -0,0 +1,54 @@ +export type RetryPolicy = { + maxRetries: number; + initialDelayMs: number; + maxDelayMs: number; + maxRetryAfterMs: number; +}; + +export const RETRY_POLICY: Omit = { + initialDelayMs: 500, + maxDelayMs: 8_000, +}; + +export const DEFAULT_MAX_RETRY_AFTER_MS = 60_000; + +const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]); + +export function isRetryableStatus(status: number): boolean { + return RETRYABLE_STATUSES.has(status); +} + +export function parseRetryAfter(header: string | null): number | undefined { + if (!header) return undefined; + + const trimmed = header.trim(); + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + const seconds = Number(trimmed); + return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined; + } + + const at = Date.parse(trimmed); + if (Number.isNaN(at)) return undefined; + + return Math.max(0, Math.ceil((at - Date.now()) / 1000)); +} + +export function nextRetryDelayMs( + policy: RetryPolicy, + attemptsMade: number, + retryAfterSeconds?: number +): number | null { + if (retryAfterSeconds !== undefined) { + const asked = retryAfterSeconds * 1000; + return asked > policy.maxRetryAfterMs ? null : asked; + } + + const exponential = policy.initialDelayMs * 2 ** attemptsMade; + const capped = Math.min(exponential, policy.maxDelayMs); + + return Math.round(capped * (0.5 + Math.random() * 0.5)); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/buzzkit/src/core/transport.ts b/packages/buzzkit/src/core/transport.ts new file mode 100644 index 00000000..ddbf6e52 --- /dev/null +++ b/packages/buzzkit/src/core/transport.ts @@ -0,0 +1,241 @@ +import { BuzzKitError, ConnectionError, type ErrorBody, resolveError, TimeoutError } from './errors'; +import type { Page, PageParams, PagePromise } from './pagination'; +import { paginate } from './pagination'; +import { + DEFAULT_MAX_RETRY_AFTER_MS, + isRetryableStatus, + nextRetryDelayMs, + parseRetryAfter, + RETRY_POLICY, + sleep, +} from './retry'; + +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +type QueryValue = string | number | boolean | undefined | null; + +type QueryParams = Record; + +export type RequestOptions = { + method: HttpMethod; + path: string; + query?: QueryParams; + body?: unknown; + headers?: Record; + idempotencyKey?: string; + signal?: AbortSignal; +}; + +type Envelope = { + success: boolean; + data: T | null; + error: ErrorBody | null; + metadata?: { timestamp: string; requestId?: string }; +}; + +const IDEMPOTENT_METHODS = new Set(['GET', 'PUT', 'DELETE']); + +export function encodeSegment(value: string): string { + return encodeURIComponent(value); +} + +export function randomIdempotencyKey(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`; +} + +function buildQuery(query: QueryParams | undefined): string { + if (!query) return ''; + + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + search.set(key, String(value)); + } + + const serialized = search.toString(); + return serialized ? `?${serialized}` : ''; +} + +function resolveSignal(timeoutMs: number, caller: AbortSignal | undefined): AbortSignal { + const timeout = AbortSignal.timeout(timeoutMs); + if (!caller) return timeout; + return AbortSignal.any([timeout, caller]); +} + +function isAbortReason(caught: unknown): boolean { + if (typeof caught !== 'object' || caught === null) return false; + + const { name } = caught as { name?: unknown }; + return name === 'TimeoutError' || name === 'AbortError'; +} + +export type TransportOptions = { + baseUrl: string; + timeoutMs: number; + maxRetries: number; + maxRetryAfterMs?: number; + headers: Record; + fetch: typeof globalThis.fetch; +}; + +export class Transport { + private readonly options: TransportOptions; + + constructor(options: TransportOptions) { + this.options = options; + } + + with(headers: Record): Transport { + const merged = { ...this.options.headers }; + for (const [name, value] of Object.entries(headers)) { + if (value === null) delete merged[name]; + else merged[name] = value; + } + + return new Transport({ ...this.options, headers: merged }); + } + + async request(options: RequestOptions): Promise { + const policy = { + ...RETRY_POLICY, + maxRetries: this.options.maxRetries, + maxRetryAfterMs: this.options.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS, + }; + const retryable = IDEMPOTENT_METHODS.has(options.method) || options.idempotencyKey !== undefined; + + let attemptsMade = 0; + for (;;) { + const outcome = await this.attempt(options); + if ('value' in outcome) return outcome.value; + + const exhausted = attemptsMade >= policy.maxRetries; + if (exhausted || !retryable || !outcome.retryable) throw outcome.error; + + const delayMs = nextRetryDelayMs(policy, attemptsMade, outcome.retryAfterSeconds); + if (delayMs === null) throw outcome.error; + + await sleep(delayMs); + attemptsMade += 1; + } + } + + requestPage( + load: (params: TParams) => Promise>, + params: TParams + ): PagePromise { + return paginate(load, params); + } + + private async attempt( + options: RequestOptions + ): Promise<{ value: T } | { error: BuzzKitError; retryable: boolean; retryAfterSeconds?: number }> { + const headers = this.buildHeaders(options); + const url = `${this.options.baseUrl}${options.path}${buildQuery(options.query)}`; + + let response: Response; + try { + response = await this.options.fetch(url, { + method: options.method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: resolveSignal(this.options.timeoutMs, options.signal), + }); + } catch (caught) { + if (isAbortReason(caught)) { + const timeout = new TimeoutError( + `Request to ${options.method} ${options.path} timed out after ${this.options.timeoutMs}ms`, + caught + ); + return { error: timeout, retryable: true }; + } + + const message = caught instanceof Error ? caught.message : String(caught); + return { + error: new ConnectionError(`Could not reach the BuzzKit API: ${message}`, { cause: caught }), + retryable: true, + }; + } + + try { + return await this.readEnvelope(response, options); + } catch (caught) { + if (isAbortReason(caught)) { + const timeout = new TimeoutError( + `Request to ${options.method} ${options.path} timed out after ${this.options.timeoutMs}ms`, + caught + ); + return { error: timeout, retryable: true }; + } + + const message = caught instanceof Error ? caught.message : String(caught); + return { + error: new ConnectionError(`The BuzzKit API response could not be read: ${message}`, { + cause: caught, + }), + retryable: true, + }; + } + } + + private async readEnvelope( + response: Response, + options: RequestOptions + ): Promise<{ value: T } | { error: BuzzKitError; retryable: boolean; retryAfterSeconds?: number }> { + const requestId = response.headers.get('request-id') ?? undefined; + const retryAfterSeconds = parseRetryAfter(response.headers.get('retry-after')); + const text = await response.text(); + + let envelope: Envelope | undefined; + if (text.length > 0) { + try { + envelope = JSON.parse(text) as Envelope; + } catch { + envelope = undefined; + } + } + + if (!response.ok || envelope?.success === false) { + const body: ErrorBody = envelope?.error ?? { + code: 'internal', + message: `The BuzzKit API answered ${response.status} for ${options.method} ${options.path}`, + }; + + return { + error: resolveError(response.status, body, { + requestId: envelope?.metadata?.requestId ?? requestId, + retryAfterSeconds, + }), + retryable: isRetryableStatus(response.status), + retryAfterSeconds, + }; + } + + if (!envelope) { + return { + error: new BuzzKitError( + `The BuzzKit API answered ${options.method} ${options.path} with a body that is not JSON`, + { status: response.status, code: 'parse', requestId } + ), + retryable: false, + }; + } + + return { value: envelope.data as T }; + } + + private buildHeaders(options: RequestOptions): Record { + const headers: Record = { + accept: 'application/json', + ...this.options.headers, + ...options.headers, + }; + + if (options.body !== undefined) headers['content-type'] = 'application/json'; + if (options.idempotencyKey) headers['idempotency-key'] = options.idempotencyKey; + + return headers; + } +} diff --git a/packages/buzzkit/src/expressions/constants.ts b/packages/buzzkit/src/expressions/constants.ts index 9de621b8..aab05b8a 100644 --- a/packages/buzzkit/src/expressions/constants.ts +++ b/packages/buzzkit/src/expressions/constants.ts @@ -12,6 +12,4 @@ export const MAX_EXPRESSION_LEAVES = 50; export const MAX_IN_VALUES = 100; -export const CHANNELS = ['push', 'email', 'sms'] as const; - export const DURATION_UNIT_SECONDS = { m: 60, h: 3600, d: 86400 } as const; diff --git a/packages/buzzkit/src/expressions/lint.ts b/packages/buzzkit/src/expressions/lint.ts index e9d78662..b022a2a6 100644 --- a/packages/buzzkit/src/expressions/lint.ts +++ b/packages/buzzkit/src/expressions/lint.ts @@ -1,6 +1,6 @@ +import { CHANNELS } from '../resources/common'; import { ATTRIBUTE_KEY_PATTERN, - CHANNELS, DURATION_PATTERN, EVENT_NAME_PATTERN, MAX_EXPRESSION_DEPTH, diff --git a/packages/buzzkit/src/expressions/types.ts b/packages/buzzkit/src/expressions/types.ts index 6b9d4b3d..b0f4b2e2 100644 --- a/packages/buzzkit/src/expressions/types.ts +++ b/packages/buzzkit/src/expressions/types.ts @@ -1,4 +1,4 @@ -import type { CHANNELS } from './constants'; +import type { CHANNELS } from '../resources/common'; export type Duration = `${number}${'m' | 'h' | 'd'}`; diff --git a/packages/buzzkit/src/index.ts b/packages/buzzkit/src/index.ts index 5e29e9cf..5c3897de 100644 --- a/packages/buzzkit/src/index.ts +++ b/packages/buzzkit/src/index.ts @@ -1 +1,45 @@ -export const version = '0.0.0'; +export { DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS } from './core/config'; + +export { + AuthenticationError, + BadRequestError, + BuzzKitError, + ConfigurationError, + ConflictError, + ConnectionError, + type ErrorBody, + isBuzzKitError, + NotFoundError, + PermissionError, + RateLimitError, + ServerError, + TimeoutError, +} from './core/errors'; + +export { + ACTOR_TYPES, + ALIAS_SOURCES, + CHANNELS, + CREDENTIAL_STATUSES, + DELIVERY_ATTEMPT_OUTCOMES, + DELIVERY_STATUSES, + ENVIRONMENTS, + EVENT_FILTER_SOURCES, + EVENT_SOURCES, + EVENT_VOLUME_RANGES, + KEY_KINDS, + LIVE_ACTIVITY_EVENTS, + MEMBER_ROLES, + MESSAGE_STATUSES, + PLATFORMS, + PROVIDERS, + RUN_STATUSES, + SOURCE_DELIVERY_OUTCOMES, + STATS_INTERVALS, + SUBSCRIPTION_STATUSES, + WEBHOOK_DELIVERY_STATUSES, + WEBHOOK_EVENT_SOURCES, + WORKFLOW_STATUSES, +} from './resources/common'; + +export * from './server/index'; diff --git a/packages/buzzkit/src/react/context.ts b/packages/buzzkit/src/react/context.ts new file mode 100644 index 00000000..727c34c0 --- /dev/null +++ b/packages/buzzkit/src/react/context.ts @@ -0,0 +1,47 @@ +import { createContext, createElement, type ReactNode, useContext, useMemo } from 'react'; +import { BuzzKitClient } from '../client/buzzkit'; +import type { BrowserOptions, Identity } from '../client/options'; +import { ConfigurationError } from '../core/errors'; + +export type BuzzKitProviderProps = BrowserOptions & { + children: ReactNode; +}; + +const BuzzKitContext = createContext(null); + +export function BuzzKitProvider(props: BuzzKitProviderProps) { + const { children, publishableKey, identity, baseUrl, timeoutMs, maxRetries, headers, fetch } = props; + + const externalId = identity?.externalId ?? null; + const identityHash = identity?.identityHash ?? null; + const headerSignature = headers === undefined ? null : JSON.stringify(headers); + + const client = useMemo(() => { + const restored = + externalId === null ? undefined : { externalId, identityHash: identityHash ?? undefined }; + return new BuzzKitClient({ + publishableKey, + identity: restored, + baseUrl, + timeoutMs, + maxRetries, + headers: headerSignature === null ? undefined : (JSON.parse(headerSignature) as typeof headers), + fetch, + }); + }, [publishableKey, externalId, identityHash, baseUrl, timeoutMs, maxRetries, headerSignature, fetch]); + + return createElement(BuzzKitContext.Provider, { value: client }, children); +} + +export function useBuzzKit(): BuzzKitClient { + const client = useContext(BuzzKitContext); + if (!client) { + throw new ConfigurationError('useBuzzKit must be called inside a '); + } + + return client; +} + +export function useIdentity(): Identity | null { + return useBuzzKit().identity; +} diff --git a/packages/buzzkit/src/react/hooks.ts b/packages/buzzkit/src/react/hooks.ts new file mode 100644 index 00000000..821ec366 --- /dev/null +++ b/packages/buzzkit/src/react/hooks.ts @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { IdentifyParams, PreferenceChanges } from '../client/buzzkit'; +import type { Subscriber } from '../resources/subscribers'; +import type { SubscriberPreference } from '../resources/topics'; +import { useBuzzKit } from './context'; + +export type AsyncState = { + data: T | null; + error: Error | null; + isLoading: boolean; +}; + +export type PreferencesResult = AsyncState & { + refresh: () => Promise; + update: (changes: PreferenceChanges) => Promise; +}; + +export type IdentifyResult = AsyncState & { + identify: (params?: IdentifyParams) => Promise; +}; + +function useMounted() { + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + return mounted; +} + +function useLatestRequest() { + const issued = useRef(0); + + return useMemo(() => { + return { + open: () => { + issued.current += 1; + return issued.current; + }, + isLatest: (token: number) => issued.current === token, + }; + }, []); +} + +function toError(caught: unknown): Error { + return caught instanceof Error ? caught : new Error(String(caught)); +} + +export function usePreferences(): PreferencesResult { + const client = useBuzzKit(); + const mounted = useMounted(); + const request = useLatestRequest(); + + const [state, setState] = useState>({ + data: null, + error: null, + isLoading: true, + }); + + const refresh = useCallback(async () => { + const token = request.open(); + setState((current) => ({ ...current, isLoading: true })); + + try { + const preferences = await client.preferences(); + if (mounted.current && request.isLatest(token)) { + setState({ data: preferences, error: null, isLoading: false }); + } + } catch (caught) { + if (mounted.current && request.isLatest(token)) { + setState({ data: null, error: toError(caught), isLoading: false }); + } + } + }, [client, mounted, request]); + + const update = useCallback( + async (changes: PreferenceChanges) => { + const token = request.open(); + setState((current) => ({ ...current, isLoading: true })); + + try { + const preferences = await client.updatePreferences(changes); + if (mounted.current && request.isLatest(token)) { + setState({ data: preferences, error: null, isLoading: false }); + } + return preferences; + } catch (caught) { + if (mounted.current && request.isLatest(token)) { + setState((current) => ({ ...current, error: toError(caught), isLoading: false })); + } + throw caught; + } + }, + [client, mounted, request] + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { ...state, refresh, update }; +} + +export function useIdentify(params?: IdentifyParams): IdentifyResult { + const client = useBuzzKit(); + const mounted = useMounted(); + const request = useLatestRequest(); + const [state, setState] = useState>({ + data: null, + error: null, + isLoading: false, + }); + + const identify = useCallback( + async (overrides?: IdentifyParams) => { + const token = request.open(); + setState((current) => ({ ...current, isLoading: true })); + + try { + const subscriber = await client.identify(overrides ?? params); + if (mounted.current && request.isLatest(token)) { + setState({ data: subscriber, error: null, isLoading: false }); + } + return subscriber; + } catch (caught) { + if (mounted.current && request.isLatest(token)) { + setState({ data: null, error: toError(caught), isLoading: false }); + } + throw caught; + } + }, + [client, mounted, params, request] + ); + + return { ...state, identify }; +} + +export function useTrack(): (name: string, data?: Record) => Promise { + const client = useBuzzKit(); + + return useCallback( + async (name: string, data?: Record) => { + await client.track(name, data); + }, + [client] + ); +} diff --git a/packages/buzzkit/src/react/index.ts b/packages/buzzkit/src/react/index.ts new file mode 100644 index 00000000..76dcc43b --- /dev/null +++ b/packages/buzzkit/src/react/index.ts @@ -0,0 +1,12 @@ +export { BuzzKitClient, type IdentifyParams, type PreferenceChanges } from '../client/buzzkit'; +export type { BrowserOptions, Identity } from '../client/options'; +export type { SubscriberPreference } from '../resources/topics'; +export { BuzzKitProvider, type BuzzKitProviderProps, useBuzzKit, useIdentity } from './context'; +export { + type AsyncState, + type IdentifyResult, + type PreferencesResult, + useIdentify, + usePreferences, + useTrack, +} from './hooks'; diff --git a/packages/buzzkit/src/resources/common.ts b/packages/buzzkit/src/resources/common.ts new file mode 100644 index 00000000..2e052da0 --- /dev/null +++ b/packages/buzzkit/src/resources/common.ts @@ -0,0 +1,77 @@ +export const CHANNELS = ['push', 'email'] as const; + +export const PLATFORMS = ['ios', 'android'] as const; + +export const ENVIRONMENTS = ['production', 'sandbox'] as const; + +export const PROVIDERS = ['apns', 'fcm', 'resend'] as const; + +export const MEMBER_ROLES = ['member', 'admin', 'owner'] as const; + +export const EVENT_SOURCES = ['server', 'ios', 'android', 'web', 'system'] as const; + +export const EVENT_FILTER_SOURCES = [...EVENT_SOURCES, 'webhook'] as const; + +export const ACTOR_TYPES = ['member', 'user', 'key', 'system'] as const; + +export const CREDENTIAL_STATUSES = ['unvalidated', 'active', 'invalid'] as const; + +export const SUBSCRIPTION_STATUSES = ['active', 'invalid'] as const; + +export const MESSAGE_STATUSES = ['queued', 'processing', 'completed', 'scheduled', 'canceled'] as const; + +export const DELIVERY_STATUSES = [ + 'pending', + 'retrying', + 'sent', + 'delivered', + 'bounced', + 'failed', + 'invalid', +] as const; + +export const DELIVERY_ATTEMPT_OUTCOMES = ['sent', 'retry', 'failed', 'invalid'] as const; + +export const WORKFLOW_STATUSES = ['draft', 'active', 'paused'] as const; + +export const RUN_STATUSES = ['running', 'sleeping', 'waiting', 'completed', 'canceled', 'failed'] as const; + +export const SOURCE_DELIVERY_OUTCOMES = ['event', 'duplicate', 'dropped', 'rejected', 'unverified'] as const; + +export const WEBHOOK_DELIVERY_STATUSES = ['pending', 'success', 'failed', 'exhausted'] as const; + +export const WEBHOOK_EVENT_SOURCES = ['audit', 'stream'] as const; + +export const ALIAS_SOURCES = ['system', 'manual'] as const; + +export const KEY_KINDS = ['workspace', 'tenant', 'client'] as const; + +export const LIVE_ACTIVITY_EVENTS = ['start', 'update', 'end'] as const; + +export const STATS_INTERVALS = ['hour', 'day', 'week', 'month'] as const; + +export const EVENT_VOLUME_RANGES = ['24h', '7d', '30d'] as const; + +export type AliasSource = (typeof ALIAS_SOURCES)[number]; + +export type KeyKind = (typeof KEY_KINDS)[number]; + +export type Channel = (typeof CHANNELS)[number]; + +export type Platform = (typeof PLATFORMS)[number]; + +export type Environment = (typeof ENVIRONMENTS)[number]; + +export type Provider = (typeof PROVIDERS)[number]; + +export type MemberRole = (typeof MEMBER_ROLES)[number]; + +export type EventSource = (typeof EVENT_FILTER_SOURCES)[number]; + +export type ActorType = (typeof ACTOR_TYPES)[number]; + +export type Attributes = Record; + +export type Metadata = Record; + +export type Deleted = T & { deleted: true }; diff --git a/packages/buzzkit/src/resources/credentials.ts b/packages/buzzkit/src/resources/credentials.ts new file mode 100644 index 00000000..99ac0baa --- /dev/null +++ b/packages/buzzkit/src/resources/credentials.ts @@ -0,0 +1,72 @@ +import type { PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Channel, CREDENTIAL_STATUSES, Deleted, Environment, Provider } from './common'; +import { listPage } from './list'; + +export type CredentialStatus = (typeof CREDENTIAL_STATUSES)[number]; + +export type Credential = { + id: string; + channel: Channel; + provider: Provider; + environment: Environment; + details: Record; + status: CredentialStatus; + validatedAt: string | null; + lastError: string | null; + createdAt: string; + updatedAt: string; +}; + +export type ApnsCredentialParams = { + provider: 'apns'; + p8: string; + teamId: string; + keyId: string; + bundleId: string; + environment?: Environment; +}; + +export type FcmCredentialParams = { + provider: 'fcm'; + serviceAccount: string | Record; +}; + +export type ResendCredentialParams = { + provider: 'resend'; + apiKey: string; +}; + +export type CreateCredentialParams = ApnsCredentialParams | FcmCredentialParams | ResendCredentialParams; + +export function credentialsResource(transport: Transport) { + return { + list(): PagePromise { + return listPage(transport, '/v1/credentials', {}); + }, + + async create(params: CreateCredentialParams): Promise { + const page = await transport.request<{ items: Credential[] }>({ + method: 'POST', + path: '/v1/credentials', + body: params, + }); + return page.items; + }, + + retrieve(id: string): Promise { + return transport.request({ method: 'GET', path: `/v1/credentials/${encodeSegment(id)}` }); + }, + + remove(id: string): Promise> { + return transport.request({ method: 'DELETE', path: `/v1/credentials/${encodeSegment(id)}` }); + }, + + validate(id: string): Promise { + return transport.request({ method: 'POST', path: `/v1/credentials/${encodeSegment(id)}/validate` }); + }, + }; +} + +export type CredentialsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/deliveries.ts b/packages/buzzkit/src/resources/deliveries.ts new file mode 100644 index 00000000..1988d8e6 --- /dev/null +++ b/packages/buzzkit/src/resources/deliveries.ts @@ -0,0 +1,62 @@ +import type { PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Channel, DELIVERY_ATTEMPT_OUTCOMES, DELIVERY_STATUSES, Provider } from './common'; +import { listPage } from './list'; + +export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number]; + +export type DeliveryAttemptOutcome = (typeof DELIVERY_ATTEMPT_OUTCOMES)[number]; + +export type Delivery = { + id: string; + messageId: string; + subscriberId: string; + subscriptionId: string; + channel: Channel; + provider: Provider; + status: DeliveryStatus; + attempts: number; + lastErrorCode: string | null; + lastErrorMessage: string | null; + providerMessageId: string | null; + nextAttemptAt: string | null; + firstAttemptedAt: string | null; + lastAttemptedAt: string | null; + sentAt: string | null; + settledAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type DeliveryAttempt = { + id: string; + deliveryId: string; + attempt: number; + provider: Provider; + outcome: DeliveryAttemptOutcome; + errorCode: string | null; + providerReason: string | null; + providerStatus: number | null; + providerMessageId: string | null; + request: unknown; + response: unknown; + latencyMs: number | null; + nextAttemptAt: string | null; + startedAt: string; + finishedAt: string; +}; + +export function deliveriesResource(transport: Transport) { + return { + retrieve(id: string): Promise { + return transport.request({ method: 'GET', path: `/v1/deliveries/${encodeSegment(id)}` }); + }, + + attempts(id: string): PagePromise { + return listPage(transport, `/v1/deliveries/${encodeSegment(id)}/attempts`, {}); + }, + }; +} + +export type DeliveriesResource = ReturnType; diff --git a/packages/buzzkit/src/resources/events.ts b/packages/buzzkit/src/resources/events.ts new file mode 100644 index 00000000..31c3793f --- /dev/null +++ b/packages/buzzkit/src/resources/events.ts @@ -0,0 +1,112 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { EVENT_VOLUME_RANGES, EventSource } from './common'; +import { listPage } from './list'; + +export type EventVolumeRange = (typeof EVENT_VOLUME_RANGES)[number]; + +export type EventRecord = { + id: string; + sequence: number; + name: string; + source: string; + externalId: string | null; + timestamp: string; + receivedAt: string; + data: Record; + runId: string | null; + messageId: string | null; + step: string | null; +}; + +export type TrackedEvent = { + id: string; + sequence: number; + externalId: string; + name: string; + source: EventSource; + timestamp: string; + receivedAt: string; + data: Record; + status: 'accepted' | 'duplicate'; +}; + +export type EventInput = { + id?: string; + externalId: string; + name: string; + timestamp?: string; + data?: Record; +}; + +export type EventName = { + name: string; + counts: { last24h: number; last7d: number; last30d: number; total: number }; + subscribers7d: number; + sources: string[]; + providers: string[]; + lastAt: string; + firstAt: string; +}; + +export type EventVolumeBucket = { + at: string; + count: number; + subscribers: number; +}; + +export type EventVolume = { + range: EventVolumeRange; + bucketSeconds: number; + from: string; + to: string; + buckets: EventVolumeBucket[]; +}; + +export type EventNameDetail = EventName & { + volume: EventVolume; + samples: EventRecord[]; +}; + +export type ListEventsParams = PageParams & { + name?: string; + source?: EventSource; + provider?: string; + after?: string; + afterId?: string; +}; + +export function eventsResource(transport: Transport) { + return { + list(params: ListEventsParams = {}): PagePromise { + return listPage(transport, '/v1/events', params); + }, + + track(events: EventInput | EventInput[]): PagePromise { + const batch = Array.isArray(events) ? events : [events]; + + return transport.requestPage(() => { + return transport.request({ method: 'POST', path: '/v1/events', body: { events: batch } }); + }, {}); + }, + + names(): PagePromise { + return listPage(transport, '/v1/events/names', {}); + }, + + name(name: string, params: { range?: EventVolumeRange } = {}): Promise { + return transport.request({ + method: 'GET', + path: `/v1/events/names/${encodeSegment(name)}`, + query: params, + }); + }, + + volume(params: { range?: EventVolumeRange; name?: string } = {}): Promise { + return transport.request({ method: 'GET', path: '/v1/events/volume', query: params }); + }, + }; +} + +export type EventsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/imports.ts b/packages/buzzkit/src/resources/imports.ts new file mode 100644 index 00000000..00963624 --- /dev/null +++ b/packages/buzzkit/src/resources/imports.ts @@ -0,0 +1,48 @@ +import type { Transport } from '../core/transport'; +import type { Attributes, Channel, Environment, Platform } from './common'; + +export type ImportRow = { + externalId: string; + channel?: Channel; + platform?: Platform; + environment?: Environment; + token?: string; + address?: string; + attributes?: Attributes; + timezone?: string; + language?: string; + country?: string; + device?: { appVersion?: string; osVersion?: string; model?: string }; + lastSeenAt?: string; + enabled?: boolean; + subscribe?: { email?: boolean }; +}; + +export type ImportFailure = { + index: number; + code: string; + message: string; + param: string | null; +}; + +export type ImportResult = { + counts: { + rows: number; + subscribersCreated: number; + subscriptionsCreated: number; + subscriptionsUpdated: number; + unchanged: number; + failed: number; + }; + failures: ImportFailure[]; +}; + +export function importsResource(transport: Transport) { + return { + create(rows: ImportRow[]): Promise { + return transport.request({ method: 'POST', path: '/v1/imports', body: { rows } }); + }, + }; +} + +export type ImportsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/index.ts b/packages/buzzkit/src/resources/index.ts new file mode 100644 index 00000000..c750fd73 --- /dev/null +++ b/packages/buzzkit/src/resources/index.ts @@ -0,0 +1,19 @@ +export type * from './common'; +export type * from './credentials'; +export type * from './deliveries'; +export type * from './events'; +export type * from './imports'; +export type * from './liveActivities'; +export type * from './messages'; +export type * from './runs'; +export type * from './secrets'; +export type * from './segments'; +export type * from './sources'; +export type * from './stats'; +export type * from './subscribers'; +export type * from './subscriptions'; +export type * from './tenants'; +export type * from './topics'; +export type * from './webhooks'; +export type * from './workflows'; +export type * from './workspaces'; diff --git a/packages/buzzkit/src/resources/list.ts b/packages/buzzkit/src/resources/list.ts new file mode 100644 index 00000000..7e8c128f --- /dev/null +++ b/packages/buzzkit/src/resources/list.ts @@ -0,0 +1,12 @@ +import type { Page, PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; + +export function listPage( + transport: Transport, + path: string, + params: TParams +): PagePromise { + return transport.requestPage((query: TParams) => { + return transport.request>({ method: 'GET', path, query }); + }, params); +} diff --git a/packages/buzzkit/src/resources/liveActivities.ts b/packages/buzzkit/src/resources/liveActivities.ts new file mode 100644 index 00000000..ed497934 --- /dev/null +++ b/packages/buzzkit/src/resources/liveActivities.ts @@ -0,0 +1,41 @@ +import type { Transport } from '../core/transport'; +import type { LIVE_ACTIVITY_EVENTS } from './common'; + +export type LiveActivityEvent = (typeof LIVE_ACTIVITY_EVENTS)[number]; + +export type LiveActivityAlert = { + title?: string; + body?: string; + sound?: string; +}; + +export type SendLiveActivityParams = { + to: string; + event: LiveActivityEvent; + activityId?: string; + attributesType?: string; + contentState: Record; + attributes?: Record; + alert?: LiveActivityAlert; + staleDate?: string; + dismissalDate?: string; + priority?: 'high' | 'normal'; + timestamp?: number; +}; + +export type LiveActivityResult = { + id: string; + ok: boolean; + code?: string; + reason?: string; +}; + +export function liveActivitiesResource(transport: Transport) { + return { + send(params: SendLiveActivityParams): Promise<{ results: LiveActivityResult[] }> { + return transport.request({ method: 'POST', path: '/v1/live-activities/send', body: params }); + }, + }; +} + +export type LiveActivitiesResource = ReturnType; diff --git a/packages/buzzkit/src/resources/messages.ts b/packages/buzzkit/src/resources/messages.ts new file mode 100644 index 00000000..a5bf8b5a --- /dev/null +++ b/packages/buzzkit/src/resources/messages.ts @@ -0,0 +1,158 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment, randomIdempotencyKey } from '../core/transport'; +import type { Expression } from '../expressions/index'; +import type { INTERRUPTION_LEVELS, SEND_PRIORITIES } from '../workflows/index'; +import type { Channel, MESSAGE_STATUSES } from './common'; +import type { Delivery, DeliveryStatus } from './deliveries'; +import { listPage } from './list'; + +export type MessageStatus = (typeof MESSAGE_STATUSES)[number]; + +export type MessagePriority = (typeof SEND_PRIORITIES)[number]; + +export type InterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]; + +export type MessageAction = { + id: string; + title: string; + destructive?: boolean; + foreground?: boolean; + input?: boolean; + placeholder?: string; +}; + +export type MessagePayload = { + title?: string; + body?: string; + subtitle?: string; + badge?: number; + sound?: string; + imageUrl?: string; + data?: Record; + collapseId?: string; + priority?: MessagePriority; + threadId?: string; + category?: string; + interruptionLevel?: InterruptionLevel; + relevanceScore?: number; + targetContentId?: string; + deepLink?: string; + action?: { name: string; data?: Record }; + policy?: 'ignore'; + actions?: MessageAction[]; + apns?: { payload?: Record }; + fcm?: { android?: Record; payload?: Record }; +}; + +export type MessageScheduleInput = { + at: string; + timezone?: string; + defaultTimezone?: string; +}; + +export type MessageSchedule = { + at: string; + timezone: string; + defaultTimezone?: string; +}; + +export type SendMessageParams = MessagePayload & { + to?: string | string[]; + topic?: string; + segment?: string; + where?: Expression; + channel?: Channel; + ttlSeconds?: number; + schedule?: MessageScheduleInput; + idempotencyKey?: string; +}; + +export type MessageTargets = { + to?: string[]; + topic?: string; + segment?: string; + segmentVersion?: string; + where?: Expression; +}; + +export type MessageCounts = { + total: number; + pending: number; + sent: number; + delivered: number; + bounced: number; + failed: number; + invalid: number; +}; + +export type Message = { + id: string; + channel: Channel; + topic: string | null; + targets: MessageTargets; + payload: MessagePayload; + run: { id: string; step: string } | null; + status: MessageStatus; + counts: MessageCounts; + idempotencyKey: string | null; + schedule: MessageSchedule | null; + scheduledFor: string | null; + canceledAt: string | null; + expiresAt: string; + createdAt: string; + updatedAt: string; + completedAt: string | null; +}; + +export type ListMessagesParams = PageParams & { + q?: string; + status?: MessageStatus; + channel?: Channel; + topic?: string; + from?: string; + to?: string; +}; + +export type ListMessageDeliveriesParams = PageParams & { + status?: DeliveryStatus; +}; + +export type MessageDelivery = Delivery & { + externalId: string; + platform: string | null; + endpoint: string | null; +}; + +export function messagesResource(transport: Transport) { + return { + list(params: ListMessagesParams = {}): PagePromise { + return listPage(transport, '/v1/messages', params); + }, + + send(params: SendMessageParams): Promise { + const { idempotencyKey, ...body } = params; + + return transport.request({ + method: 'POST', + path: '/v1/messages', + body, + idempotencyKey: idempotencyKey ?? randomIdempotencyKey(), + }); + }, + + retrieve(id: string): Promise { + return transport.request({ method: 'GET', path: `/v1/messages/${encodeSegment(id)}` }); + }, + + cancel(id: string): Promise { + return transport.request({ method: 'POST', path: `/v1/messages/${encodeSegment(id)}/cancel` }); + }, + + deliveries(id: string, params: ListMessageDeliveriesParams = {}): PagePromise { + return listPage(transport, `/v1/messages/${encodeSegment(id)}/deliveries`, params); + }, + }; +} + +export type MessagesResource = ReturnType; diff --git a/packages/buzzkit/src/resources/runs.ts b/packages/buzzkit/src/resources/runs.ts new file mode 100644 index 00000000..c7210581 --- /dev/null +++ b/packages/buzzkit/src/resources/runs.ts @@ -0,0 +1,51 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { RUN_STATUSES } from './common'; +import type { EventRecord } from './events'; +import { listPage } from './list'; + +export type RunStatus = (typeof RUN_STATUSES)[number]; + +export type Run = { + id: string; + workflowId: string; + workflow: string; + versionId: string; + externalId: string; + status: RunStatus; + step: string | null; + summary: string | null; + startedAt: string; + updatedAt: string; +}; + +export type RunDetail = Run & { + events: EventRecord[]; +}; + +export type RunCounts = { + running: number; + sleeping: number; + waiting: number; + steps: Record; +}; + +export type ListRunsParams = PageParams & { + status?: RunStatus; + workflow?: string; +}; + +export function runsResource(transport: Transport) { + return { + list(params: ListRunsParams = {}): PagePromise { + return listPage(transport, '/v1/runs', params); + }, + + retrieve(runId: string): Promise { + return transport.request({ method: 'GET', path: `/v1/runs/${encodeSegment(runId)}` }); + }, + }; +} + +export type RunsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/secrets.ts b/packages/buzzkit/src/resources/secrets.ts new file mode 100644 index 00000000..fb5ca0ff --- /dev/null +++ b/packages/buzzkit/src/resources/secrets.ts @@ -0,0 +1,39 @@ +import type { PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Deleted } from './common'; +import { listPage } from './list'; + +export type Secret = { + id: string; + name: string; + version: number; + createdAt: string; + updatedAt: string; +}; + +export function secretsResource(transport: Transport) { + return { + list(): PagePromise { + return listPage(transport, '/v1/secrets', {}); + }, + + retrieve(name: string): Promise { + return transport.request({ method: 'GET', path: `/v1/secrets/${encodeSegment(name)}` }); + }, + + upsert(name: string, value: string): Promise { + return transport.request({ + method: 'PUT', + path: `/v1/secrets/${encodeSegment(name)}`, + body: { value }, + }); + }, + + remove(name: string): Promise> { + return transport.request({ method: 'DELETE', path: `/v1/secrets/${encodeSegment(name)}` }); + }, + }; +} + +export type SecretsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/segments.ts b/packages/buzzkit/src/resources/segments.ts new file mode 100644 index 00000000..038232b4 --- /dev/null +++ b/packages/buzzkit/src/resources/segments.ts @@ -0,0 +1,80 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Expression } from '../expressions/index'; +import type { Deleted } from './common'; +import { listPage } from './list'; +import type { SubscriberListItem } from './subscribers'; + +export type SegmentVersion = { + id: string; + number: number; + expression: Expression; + createdAt: string; +}; + +export type Segment = { + id: string; + slug: string; + name: string; + description: string | null; + version: SegmentVersion | null; + createdAt: string; + updatedAt: string; +}; + +export type SegmentPreview = { + count: number; + sample: SubscriberListItem[]; +}; + +export type CreateSegmentParams = { + slug: string; + name: string; + description?: string; + expression: Expression; +}; + +export type UpdateSegmentParams = { + name?: string; + description?: string | null; + expression?: Expression; +}; + +export function segmentsResource(transport: Transport) { + return { + list(): PagePromise { + return listPage(transport, '/v1/segments', {}); + }, + + create(params: CreateSegmentParams): Promise { + return transport.request({ method: 'POST', path: '/v1/segments', body: params }); + }, + + preview(expression: Expression): Promise { + return transport.request({ method: 'POST', path: '/v1/segments/preview', body: { expression } }); + }, + + retrieve(slug: string): Promise { + return transport.request({ method: 'GET', path: `/v1/segments/${encodeSegment(slug)}` }); + }, + + update(slug: string, params: UpdateSegmentParams): Promise { + return transport.request({ + method: 'PATCH', + path: `/v1/segments/${encodeSegment(slug)}`, + body: params, + }); + }, + + remove(slug: string): Promise> { + return transport.request({ method: 'DELETE', path: `/v1/segments/${encodeSegment(slug)}` }); + }, + + members(slug: string, params: PageParams = {}): PagePromise { + return listPage(transport, `/v1/segments/${encodeSegment(slug)}/members`, params); + }, + }; +} + +export type SegmentsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/sources.ts b/packages/buzzkit/src/resources/sources.ts new file mode 100644 index 00000000..43ae49f0 --- /dev/null +++ b/packages/buzzkit/src/resources/sources.ts @@ -0,0 +1,123 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { + SourceMapping as GrammarSourceMapping, + SourceProvider as GrammarSourceProvider, + SourceStatus as GrammarSourceStatus, + Verification as GrammarVerification, +} from '../sources/index'; +import type { Deleted, SOURCE_DELIVERY_OUTCOMES } from './common'; +import { listPage } from './list'; + +export type SourcePreset = GrammarSourceProvider; + +export type SourceProvider = SourcePreset | (string & {}); + +export type SourceStatus = GrammarSourceStatus; + +export type SourceMapping = GrammarSourceMapping; + +export type SourceVerification = GrammarVerification; + +export type Source = { + id: string; + name: string; + provider: SourceProvider; + status: SourceStatus; + url: string; + mapping: SourceMapping; + verification: SourceVerification; + hasSecret: boolean; + lastDeliveryAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type SourceDeliveryOutcome = (typeof SOURCE_DELIVERY_OUTCOMES)[number]; + +export type SourceDelivery = { + id: string; + sourceId: string; + providerEventId: string | null; + providerType: string | null; + outcome: SourceDeliveryOutcome; + reason: string | null; + detail: string | null; + subscriberId: string | null; + event: string | null; + eventId: string | null; + payload: unknown; + receivedAt: string; +}; + +export type CreateSourceParams = { + name: string; + provider: SourceProvider; + verification?: SourceVerification; + mapping?: SourceMapping; + secret?: string; +}; + +export type UpdateSourceParams = Partial & { + status?: 'active' | 'paused'; +}; + +export type SourcePreviewParams = { + payload: Record; + headers?: Record; + mapping?: SourceMapping; +}; + +export type SourcePreview = + | { + outcome: 'dropped'; + reason: string; + detail: string; + suggestions: unknown; + } + | { + outcome: 'event'; + event: Record & { externalId: string }; + suggestions: unknown; + }; + +export type ListSourceDeliveriesParams = PageParams & { + outcome?: SourceDeliveryOutcome; +}; + +export function sourcesResource(transport: Transport) { + const base = (id: string) => `/v1/sources/${encodeSegment(id)}`; + + return { + list(): PagePromise { + return listPage(transport, '/v1/sources', {}); + }, + + create(params: CreateSourceParams): Promise { + return transport.request({ method: 'POST', path: '/v1/sources', body: params }); + }, + + retrieve(id: string): Promise { + return transport.request({ method: 'GET', path: base(id) }); + }, + + update(id: string, params: UpdateSourceParams): Promise { + return transport.request({ method: 'PATCH', path: base(id), body: params }); + }, + + remove(id: string): Promise> { + return transport.request({ method: 'DELETE', path: base(id) }); + }, + + preview(id: string, params: SourcePreviewParams): Promise { + return transport.request({ method: 'POST', path: `${base(id)}/preview`, body: params }); + }, + + deliveries(id: string, params: ListSourceDeliveriesParams = {}): PagePromise { + return listPage(transport, `${base(id)}/deliveries`, params); + }, + }; +} + +export type SourcesResource = ReturnType; diff --git a/packages/buzzkit/src/resources/stats.ts b/packages/buzzkit/src/resources/stats.ts new file mode 100644 index 00000000..d07f1aa8 --- /dev/null +++ b/packages/buzzkit/src/resources/stats.ts @@ -0,0 +1,86 @@ +import type { Transport } from '../core/transport'; +import type { STATS_INTERVALS } from './common'; + +export type StatsInterval = (typeof STATS_INTERVALS)[number]; + +export type DeliveryTotals = { + total: number; + sent: number; + delivered: number; + failed: number; + capped: number; + invalid: number; + pending: number; +}; + +export type RunTotals = { + started: number; + live: number; + completed: number; + canceled: number; + failed: number; +}; + +export type StatsDay = { + date: string; + subscribers: number; + messages: number; + sent: number; + delivered: number; + failed: number; + capped: number; + invalid: number; + pending: number; + events: number; + runsStarted: number; + runsCompleted: number; + runsFailed: number; +}; + +export type StatsWindow = { + subscribers: { added: number }; + messages: { total: number }; + deliveries: DeliveryTotals; + events: { total: number }; + runs: RunTotals; +}; + +export type StatsWorkflow = { + slug: string; + name: string; + running: number; + sleeping: number; + waiting: number; + lastRunAt: string | null; +}; + +export type Stats = { + range: { from: string; to: string }; + interval: StatsInterval; + subscribers: { total: number; added: number }; + messages: { total: number }; + deliveries: DeliveryTotals; + events: { total: number }; + runs: RunTotals; + topEvents: Array<{ name: string; count: number }>; + workflows: StatsWorkflow[]; + scheduled: { count: number; nextAt: string | null }; + previous: StatsWindow; + series: StatsDay[]; +}; + +export type StatsParams = { + from?: string; + to?: string; + interval?: StatsInterval; +}; + +export function statsResource(transport: Transport) { + return { + retrieve(params: StatsParams = {}): Promise { + return transport.request({ method: 'GET', path: '/v1/stats', query: params }); + }, + }; +} + +export type StatsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/subscribers.ts b/packages/buzzkit/src/resources/subscribers.ts new file mode 100644 index 00000000..ae9b2807 --- /dev/null +++ b/packages/buzzkit/src/resources/subscribers.ts @@ -0,0 +1,134 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { AliasSource, Attributes, Channel, Deleted, EventSource } from './common'; +import type { Delivery } from './deliveries'; +import type { EventRecord } from './events'; +import { listPage } from './list'; +import type { Run } from './runs'; +import type { Subscription } from './subscriptions'; +import type { SubscriberPreference } from './topics'; + +export type Subscriber = { + id: string; + externalId: string; + attributes: Attributes; + verified: boolean; + identityVerifiedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type SubscriberWithSubscriptions = Subscriber & { + subscriptions: Subscription[]; +}; + +export type SubscriberListItem = Subscriber & { + lastSeenAt: string | null; + channels: string[]; + platforms: string[]; +}; + +export type UpsertSubscriberParams = { + attributes?: Attributes; + email?: string; + subscribe?: { email?: boolean }; + timezone?: string; +}; + +export type ListSubscribersParams = PageParams & { + search?: string; +}; + +export type SubscriberTimelineParams = PageParams & { + name?: string; + source?: EventSource; + provider?: string; +}; + +export type SubscriberDelivery = Delivery & { + message: { + id: string; + channel: Channel; + topic: string | null; + title: string | null; + body: string | null; + createdAt: string; + }; +}; + +export type SubscriberAlias = { + externalId: string; + source: AliasSource; + createdAt: string; +}; + +export type PreferenceChanges = Record>>; + +export function subscribersResource(transport: Transport) { + const base = (externalId: string) => `/v1/subscribers/${encodeSegment(externalId)}`; + + return { + list(params: ListSubscribersParams = {}): PagePromise { + return listPage(transport, '/v1/subscribers', params); + }, + + retrieve(externalId: string): Promise { + return transport.request({ method: 'GET', path: base(externalId) }); + }, + + upsert(externalId: string, params: UpsertSubscriberParams = {}): Promise { + return transport.request({ method: 'PUT', path: base(externalId), body: params }); + }, + + remove(externalId: string): Promise> { + return transport.request({ method: 'DELETE', path: base(externalId) }); + }, + + aliases(externalId: string): PagePromise { + return listPage(transport, `${base(externalId)}/aliases`, {}); + }, + + addAlias(externalId: string, alias: string): PagePromise { + return transport.requestPage(() => { + return transport.request({ + method: 'POST', + path: `${base(externalId)}/aliases`, + body: { externalId: alias }, + }); + }, {}); + }, + + subscriptions(externalId: string): PagePromise { + return listPage(transport, `${base(externalId)}/subscriptions`, {}); + }, + + preferences(externalId: string): PagePromise { + return listPage(transport, `${base(externalId)}/preferences`, {}); + }, + + updatePreferences(externalId: string, preferences: PreferenceChanges): PagePromise { + return transport.requestPage(() => { + return transport.request({ + method: 'PATCH', + path: `${base(externalId)}/preferences`, + body: { preferences }, + }); + }, {}); + }, + + deliveries(externalId: string, params: PageParams = {}): PagePromise { + return listPage(transport, `${base(externalId)}/deliveries`, params); + }, + + timeline(externalId: string, params: SubscriberTimelineParams = {}): PagePromise { + return listPage(transport, `${base(externalId)}/timeline`, params); + }, + + runs(externalId: string): PagePromise { + return listPage(transport, `${base(externalId)}/runs`, {}); + }, + }; +} + +export type SubscribersResource = ReturnType; diff --git a/packages/buzzkit/src/resources/subscriptions.ts b/packages/buzzkit/src/resources/subscriptions.ts new file mode 100644 index 00000000..5d1bff45 --- /dev/null +++ b/packages/buzzkit/src/resources/subscriptions.ts @@ -0,0 +1,56 @@ +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Channel, Deleted, Environment, Platform, SUBSCRIPTION_STATUSES } from './common'; + +export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number]; + +export type Subscription = { + id: string; + subscriberId: string; + channel: Channel; + platform: Platform | null; + environment: Environment; + endpoint: string; + enabled: boolean; + status: SubscriptionStatus; + lastSeenAt: string; + createdAt: string; + updatedAt: string; +}; + +export type CreateSubscriptionParams = { + externalId: string; + channel?: Channel; + platform?: Platform; + environment?: Environment; + token?: string; + address?: string; +}; + +export type RegisteredSubscription = Subscription & { externalId: string }; + +export function subscriptionsResource(transport: Transport) { + return { + create(params: CreateSubscriptionParams): Promise { + return transport.request({ method: 'POST', path: '/v1/subscriptions', body: params }); + }, + + retrieve(id: string): Promise { + return transport.request({ method: 'GET', path: `/v1/subscriptions/${encodeSegment(id)}` }); + }, + + update(id: string, params: { enabled: boolean }): Promise { + return transport.request({ + method: 'PATCH', + path: `/v1/subscriptions/${encodeSegment(id)}`, + body: params, + }); + }, + + remove(id: string): Promise> { + return transport.request({ method: 'DELETE', path: `/v1/subscriptions/${encodeSegment(id)}` }); + }, + }; +} + +export type SubscriptionsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/tenants.ts b/packages/buzzkit/src/resources/tenants.ts new file mode 100644 index 00000000..d9d0d650 --- /dev/null +++ b/packages/buzzkit/src/resources/tenants.ts @@ -0,0 +1,81 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Deleted, Metadata } from './common'; +import { listPage } from './list'; + +export type QuietHours = { + from: string; + to: string; + timezone: string; +}; + +export type SendPolicy = { + quietHours: QuietHours | null; + dailyCap: number | null; +}; + +export type TenantSettings = { + identity: { requireVerification: boolean }; + channels: Record<'push' | 'email', { enabled: boolean }>; + sendPolicy: SendPolicy; +}; + +export type TenantSettingsPatch = { + identity?: { requireVerification?: boolean }; + channels?: Partial>; + sendPolicy?: { + quietHours?: { from: string; to: string; timezone?: string } | null; + dailyCap?: number | null; + }; +}; + +export type Tenant = { + id: string; + name: string; + slug: string; + isDefault: boolean; + metadata: Metadata; + settings: TenantSettings; + createdAt: string; + updatedAt: string; +}; + +export type CreateTenantParams = { + name: string; + slug: string; + metadata?: Metadata; +}; + +export type UpdateTenantParams = { + name?: string; + slug?: string; + metadata?: Metadata; + settings?: TenantSettingsPatch; +}; + +export function tenantsResource(transport: Transport) { + return { + list(params: PageParams = {}): PagePromise { + return listPage(transport, '/v1/tenants', params); + }, + + create(params: CreateTenantParams): Promise { + return transport.request({ method: 'POST', path: '/v1/tenants', body: params }); + }, + + retrieve(slug: string): Promise { + return transport.request({ method: 'GET', path: `/v1/tenants/${encodeSegment(slug)}` }); + }, + + update(slug: string, params: UpdateTenantParams): Promise { + return transport.request({ method: 'PATCH', path: `/v1/tenants/${encodeSegment(slug)}`, body: params }); + }, + + remove(slug: string): Promise> { + return transport.request({ method: 'DELETE', path: `/v1/tenants/${encodeSegment(slug)}` }); + }, + }; +} + +export type TenantsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/topics.ts b/packages/buzzkit/src/resources/topics.ts new file mode 100644 index 00000000..7a4381d2 --- /dev/null +++ b/packages/buzzkit/src/resources/topics.ts @@ -0,0 +1,113 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Channel, Deleted } from './common'; +import { listPage } from './list'; + +export type ChannelDefaults = Partial>; + +export type Topic = { + id: string; + slug: string; + name: string; + description: string | null; + category: string | null; + dailyCap: number | null; + channels: Channel[]; + defaultOptedIn: boolean; + channelDefaults: ChannelDefaults; + createdAt: string; + updatedAt: string; +}; + +export type TopicCategory = { + id: string; + name: string; + topicCount?: number; + createdAt: string; + updatedAt: string; +}; + +export type ChannelPreference = { + optedIn: boolean; + isDefault: boolean; +}; + +export type SubscriberPreference = { + id: string; + slug: string; + name: string; + description: string | null; + category: string | null; + channels: Partial>; +}; + +export type CreateTopicParams = { + slug: string; + name: string; + description?: string; + category?: string; + dailyCap?: number; + channels?: Channel[]; + defaultOptedIn?: boolean; + channelDefaults?: ChannelDefaults; +}; + +export type UpdateTopicParams = { + slug?: string; + name?: string; + description?: string | null; + category?: string | null; + dailyCap?: number | null; + channels?: Channel[]; + defaultOptedIn?: boolean; + channelDefaults?: ChannelDefaults; +}; + +export function topicsResource(transport: Transport) { + return { + list(params: PageParams = {}): PagePromise { + return listPage(transport, '/v1/topics', params); + }, + + create(params: CreateTopicParams): Promise { + return transport.request({ method: 'POST', path: '/v1/topics', body: params }); + }, + + retrieve(slug: string): Promise { + return transport.request({ method: 'GET', path: `/v1/topics/${encodeSegment(slug)}` }); + }, + + update(slug: string, params: UpdateTopicParams): Promise { + return transport.request({ method: 'PATCH', path: `/v1/topics/${encodeSegment(slug)}`, body: params }); + }, + + remove(slug: string): Promise> { + return transport.request({ method: 'DELETE', path: `/v1/topics/${encodeSegment(slug)}` }); + }, + }; +} + +export function topicCategoriesResource(transport: Transport) { + return { + list(): PagePromise { + return listPage(transport, '/v1/topic-categories', {}); + }, + + update(id: string, params: { name: string }): Promise { + return transport.request({ + method: 'PATCH', + path: `/v1/topic-categories/${encodeSegment(id)}`, + body: params, + }); + }, + + remove(id: string): Promise> { + return transport.request({ method: 'DELETE', path: `/v1/topic-categories/${encodeSegment(id)}` }); + }, + }; +} + +export type TopicsResource = ReturnType; + +export type TopicCategoriesResource = ReturnType; diff --git a/packages/buzzkit/src/resources/webhooks.ts b/packages/buzzkit/src/resources/webhooks.ts new file mode 100644 index 00000000..439349be --- /dev/null +++ b/packages/buzzkit/src/resources/webhooks.ts @@ -0,0 +1,150 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { Deleted, WEBHOOK_DELIVERY_STATUSES, WEBHOOK_EVENT_SOURCES } from './common'; +import { listPage } from './list'; + +export type WebhookDeliveryStatus = (typeof WEBHOOK_DELIVERY_STATUSES)[number]; + +export type WebhookEndpoint = { + id: string; + tenantId: string | null; + url: string; + description: string | null; + events: string[]; + enabled: boolean; + disabledAt: string | null; + disabledReason: string | null; + failingSince: string | null; + createdAt: string; + updatedAt: string; +}; + +export type WebhookEndpointWithSecret = WebhookEndpoint & { + secret: string; + previousSecret: string | null; + previousSecretExpiresAt: string | null; +}; + +export type WebhookEvent = { + id: string; + type: string; + source: (typeof WEBHOOK_EVENT_SOURCES)[number]; + tenantId: string | null; + payload: Record; + createdAt: string; +}; + +export type WebhookAttempt = { + id: string; + attempt: number; + status: number | null; + error: string | null; + durationMs: number; + responseBody: string | null; + createdAt: string; +}; + +export type WebhookDelivery = { + id: string; + endpointId: string; + eventId: string; + eventType: string | null; + status: WebhookDeliveryStatus; + attempts: number; + nextAttemptAt: string | null; + lastStatus: number | null; + lastError: string | null; + lastAttemptAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type WebhookDeliveryDetail = Omit & { + attempts: WebhookAttempt[]; + event: WebhookEvent | null; +}; + +export type WebhookCatalogGroup = { + label: string; + wildcard?: string; + options: string[]; +}; + +export type CreateWebhookParams = { + url: string; + description?: string; + events?: string[]; + tenant?: string; +}; + +export type UpdateWebhookParams = { + url?: string; + description?: string; + events?: string[]; + tenant?: string; + enabled?: boolean; +}; + +export type ListWebhookDeliveriesParams = PageParams & { + status?: WebhookDeliveryStatus; +}; + +export function webhooksResource(transport: Transport, workspaceSlug: string) { + const base = `/v1/workspaces/${encodeSegment(workspaceSlug)}/webhooks`; + const endpoint = (id: string) => `${base}/${encodeSegment(id)}`; + + return { + list(): PagePromise { + return listPage(transport, base, {}); + }, + + create(params: CreateWebhookParams): Promise { + return transport.request({ method: 'POST', path: base, body: params }); + }, + + catalog(): Promise<{ groups: WebhookCatalogGroup[] }> { + return transport.request({ method: 'GET', path: `${base}/catalog` }); + }, + + retrieve(id: string): Promise { + return transport.request({ method: 'GET', path: endpoint(id) }); + }, + + update(id: string, params: UpdateWebhookParams): Promise { + return transport.request({ method: 'PATCH', path: endpoint(id), body: params }); + }, + + remove(id: string): Promise> { + return transport.request({ method: 'DELETE', path: endpoint(id) }); + }, + + rotate(id: string): Promise { + return transport.request({ method: 'POST', path: `${endpoint(id)}/rotate` }); + }, + + event(id: string): Promise { + return transport.request({ method: 'GET', path: `${base}/events/${encodeSegment(id)}` }); + }, + + deliveries(id: string, params: ListWebhookDeliveriesParams = {}): PagePromise { + return listPage(transport, `${endpoint(id)}/deliveries`, params); + }, + + delivery(id: string, deliveryId: string): Promise { + return transport.request({ + method: 'GET', + path: `${endpoint(id)}/deliveries/${encodeSegment(deliveryId)}`, + }); + }, + + replay(id: string, deliveryId: string): Promise { + return transport.request({ + method: 'POST', + path: `${endpoint(id)}/deliveries/${encodeSegment(deliveryId)}/replay`, + }); + }, + }; +} + +export type WebhooksResource = ReturnType; diff --git a/packages/buzzkit/src/resources/workflows.ts b/packages/buzzkit/src/resources/workflows.ts new file mode 100644 index 00000000..450fb267 --- /dev/null +++ b/packages/buzzkit/src/resources/workflows.ts @@ -0,0 +1,147 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { WorkflowSpec as GrammarWorkflowSpec, TriggerSource } from '../workflows/index'; +import type { Deleted, WORKFLOW_STATUSES } from './common'; +import { listPage } from './list'; +import type { Run, RunCounts, RunStatus } from './runs'; + +export type WorkflowSpec = GrammarWorkflowSpec; + +export type WorkflowStatus = (typeof WORKFLOW_STATUSES)[number]; + +export type WorkflowVersion = { + id: string; + number: number; + publishedAt: string | null; + createdAt: string; +}; + +export type Workflow = { + id: string; + slug: string; + name: string; + description: string | null; + status: WorkflowStatus; + trigger: GrammarWorkflowSpec['trigger']; + spec: WorkflowSpec; + current: WorkflowVersion | null; + draft: WorkflowVersion | null; + versions?: Array; + runs?: RunCounts; + createdAt: string; + updatedAt: string; +}; + +export type CreateWorkflowParams = { + slug: string; + name: string; + description?: string | null; + spec: WorkflowSpec; +}; + +export type UpdateWorkflowParams = { + name?: string; + description?: string | null; + spec?: WorkflowSpec; +}; + +export type WorkflowScheduleFire = { + firedAt: string; + zones: string[]; + version: number; + started: number; + finishedAt: string | null; +}; + +export type WorkflowSchedule = { + schedule: string; + timezone: string; + defaultTimezone: string; + segment: string | null; + next: Array<{ zone: string; at: string }>; + fires: WorkflowScheduleFire[]; +}; + +export type WorkflowStepTrace = { + step: string; + status: string; + summary: string; + detail: Record | null; + at: string; +}; + +export type TestWorkflowParams = { + version?: number; + externalId?: string; + attributes?: Record; + event?: { name: string; data?: Record; source?: TriggerSource }; + at?: string; + assume?: Record; +}; + +export type WorkflowTestResult = { + version: number; + trigger: { name: string; data: Record; source: string }; + subscriber: string | null; + outcome: 'completed' | 'failed'; + exited: boolean; + error: string | null; + step: string | null; + path: string[]; + steps: WorkflowStepTrace[]; + vars: Record; + lint: unknown; +}; + +export type ListWorkflowRunsParams = PageParams & { + status?: RunStatus; +}; + +export function workflowsResource(transport: Transport) { + const base = (slug: string) => `/v1/workflows/${encodeSegment(slug)}`; + + return { + list(): PagePromise { + return listPage(transport, '/v1/workflows', {}); + }, + + create(params: CreateWorkflowParams): Promise { + return transport.request({ method: 'POST', path: '/v1/workflows', body: params }); + }, + + retrieve(slug: string): Promise { + return transport.request({ method: 'GET', path: base(slug) }); + }, + + update(slug: string, params: UpdateWorkflowParams): Promise { + return transport.request({ method: 'PATCH', path: base(slug), body: params }); + }, + + remove(slug: string): Promise> { + return transport.request({ method: 'DELETE', path: base(slug) }); + }, + + publish(slug: string): Promise { + return transport.request({ method: 'POST', path: `${base(slug)}/publish` }); + }, + + pause(slug: string): Promise { + return transport.request({ method: 'POST', path: `${base(slug)}/pause` }); + }, + + runs(slug: string, params: ListWorkflowRunsParams = {}): PagePromise { + return listPage(transport, `${base(slug)}/runs`, params); + }, + + schedule(slug: string): Promise { + return transport.request({ method: 'GET', path: `${base(slug)}/schedule` }); + }, + + test(slug: string, params: TestWorkflowParams = {}): Promise { + return transport.request({ method: 'POST', path: `${base(slug)}/test`, body: params }); + }, + }; +} + +export type WorkflowsResource = ReturnType; diff --git a/packages/buzzkit/src/resources/workspaces.ts b/packages/buzzkit/src/resources/workspaces.ts new file mode 100644 index 00000000..317c2c1a --- /dev/null +++ b/packages/buzzkit/src/resources/workspaces.ts @@ -0,0 +1,114 @@ +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import { encodeSegment } from '../core/transport'; +import type { ActorType, MemberRole } from './common'; +import { listPage } from './list'; + +export type Workspace = { + id: string; + name: string; + slug: string; + avatarUrl: string | null; + createdAt: string; + updatedAt: string; +}; + +export type WorkspaceMember = { + id: string; + role: MemberRole; + createdAt: string; + updatedAt: string; +}; + +export type AuditActorType = ActorType; + +export type AuditEvent = { + id: string; + event: string; + tenantId: string | null; + actorType: AuditActorType; + actorDisplay: string; + actorMemberId: string | null; + actorKeyId: string | null; + targetType: string | null; + targetId: string | null; + data: Record | null; + requestId: string | null; + ip: string | null; + userAgent: string | null; + createdAt: string; +}; + +export type CreateWorkspaceParams = { + name: string; + slug: string; + avatarUrl?: string; +}; + +export type UpdateWorkspaceParams = { + name?: string; + slug?: string; + avatarUrl?: string; +}; + +export type ListAuditParams = PageParams & { + q?: string; + event?: string; + actorType?: AuditActorType; + from?: string; + to?: string; +}; + +export function workspacesResource(transport: Transport) { + return { + list(): PagePromise { + return listPage(transport, '/v1/workspaces', {}); + }, + + create(params: CreateWorkspaceParams): Promise { + return transport.request({ method: 'POST', path: '/v1/workspaces', body: params }); + }, + + retrieve(slug: string): Promise { + return transport.request({ method: 'GET', path: `/v1/workspaces/${encodeSegment(slug)}` }); + }, + + update(slug: string, params: UpdateWorkspaceParams): Promise { + return transport.request({ + method: 'PATCH', + path: `/v1/workspaces/${encodeSegment(slug)}`, + body: params, + }); + }, + }; +} + +export function membersResource(transport: Transport, workspaceSlug: string) { + const base = `/v1/workspaces/${encodeSegment(workspaceSlug)}/members`; + + return { + list(): PagePromise { + return listPage(transport, base, {}); + }, + + retrieve(id: string): Promise { + return transport.request({ method: 'GET', path: `${base}/${encodeSegment(id)}` }); + }, + }; +} + +export function auditResource(transport: Transport, workspaceSlug: string) { + const base = `/v1/workspaces/${encodeSegment(workspaceSlug)}/audit`; + + return { + list(params: ListAuditParams = {}): PagePromise { + return listPage(transport, base, params); + }, + }; +} + +export type WorkspacesResource = ReturnType; + +export type MembersResource = ReturnType; + +export type AuditResource = ReturnType; diff --git a/packages/buzzkit/src/server/buzzkit.ts b/packages/buzzkit/src/server/buzzkit.ts new file mode 100644 index 00000000..d2961749 --- /dev/null +++ b/packages/buzzkit/src/server/buzzkit.ts @@ -0,0 +1,203 @@ +import { ConfigurationError } from '../core/errors'; +import type { + Page as CorePage, + PageParams as CorePageParams, + PagePromise as CorePagePromise, +} from '../core/pagination'; +import type { Expression as SegmentExpression } from '../expressions/index'; +import type * as R from '../resources/index'; +import type { TenantsResource, WorkspacesResource } from '../resources/index'; +import { tenantsResource } from '../resources/tenants'; +import { workspacesResource } from '../resources/workspaces'; +import type { ClientOptions, ResolvedOptions } from './options'; +import { resolveOptions, serverTransport } from './options'; +import { TenantScope, WorkspaceScope } from './scopes'; + +type ClientHealth = { + status: string; + database: { status: string; latencyMs: number }; +}; + +export class BuzzKit extends TenantScope { + readonly tenants: TenantsResource; + readonly workspaces: WorkspacesResource; + + private readonly options: ResolvedOptions; + + constructor(options: ClientOptions = {}) { + const resolved = resolveOptions(options); + super(serverTransport(resolved)); + this.options = resolved; + this.tenants = tenantsResource(this.transport); + this.workspaces = workspacesResource(this.transport); + } + + tenant(slug: string): TenantScope { + return new TenantScope(this.transport.with({ 'buzzkit-tenant': slug })); + } + + workspace(slug?: string): WorkspaceScope { + const resolved = slug ?? this.options.workspace; + if (!resolved) { + throw new ConfigurationError( + 'No workspace selected — call buzzkit.workspace(slug) or pass { workspace } to the client' + ); + } + + return new WorkspaceScope(this.transport.with({ 'buzzkit-workspace': resolved }), resolved); + } + + health(): Promise { + return this.transport.request({ method: 'GET', path: '/v1/health' }); + } +} + +export namespace BuzzKit { + export type Options = ClientOptions; + export type Expression = SegmentExpression; + export type Health = ClientHealth; + export type KeyKind = R.KeyKind; + export type Page = CorePage; + export type PageParams = CorePageParams; + export type PagePromise = CorePagePromise; + export type AliasSource = R.AliasSource; + export type ApnsCredentialParams = R.ApnsCredentialParams; + export type ActorType = R.ActorType; + export type Attributes = R.Attributes; + export type AuditActorType = R.AuditActorType; + export type AuditEvent = R.AuditEvent; + export type Channel = R.Channel; + export type ChannelDefaults = R.ChannelDefaults; + export type ChannelPreference = R.ChannelPreference; + export type CreateCredentialParams = R.CreateCredentialParams; + export type CreateSegmentParams = R.CreateSegmentParams; + export type CreateSourceParams = R.CreateSourceParams; + export type CreateSubscriptionParams = R.CreateSubscriptionParams; + export type CreateTenantParams = R.CreateTenantParams; + export type CreateTopicParams = R.CreateTopicParams; + export type CreateWebhookParams = R.CreateWebhookParams; + export type CreateWorkflowParams = R.CreateWorkflowParams; + export type CreateWorkspaceParams = R.CreateWorkspaceParams; + export type Credential = R.Credential; + export type CredentialStatus = R.CredentialStatus; + export type Deleted = R.Deleted; + export type Delivery = R.Delivery; + export type DeliveryAttempt = R.DeliveryAttempt; + export type DeliveryAttemptOutcome = R.DeliveryAttemptOutcome; + export type DeliveryStatus = R.DeliveryStatus; + export type DeliveryTotals = R.DeliveryTotals; + export type Environment = R.Environment; + export type EventInput = R.EventInput; + export type EventName = R.EventName; + export type EventNameDetail = R.EventNameDetail; + export type EventRecord = R.EventRecord; + export type EventSource = R.EventSource; + export type EventVolume = R.EventVolume; + export type EventVolumeBucket = R.EventVolumeBucket; + export type EventVolumeRange = R.EventVolumeRange; + export type FcmCredentialParams = R.FcmCredentialParams; + export type ImportFailure = R.ImportFailure; + export type ImportResult = R.ImportResult; + export type ImportRow = R.ImportRow; + export type InterruptionLevel = R.InterruptionLevel; + export type ListAuditParams = R.ListAuditParams; + export type ListEventsParams = R.ListEventsParams; + export type ListMessageDeliveriesParams = R.ListMessageDeliveriesParams; + export type ListMessagesParams = R.ListMessagesParams; + export type ListRunsParams = R.ListRunsParams; + export type ListSourceDeliveriesParams = R.ListSourceDeliveriesParams; + export type ListSubscribersParams = R.ListSubscribersParams; + export type ListWebhookDeliveriesParams = R.ListWebhookDeliveriesParams; + export type ListWorkflowRunsParams = R.ListWorkflowRunsParams; + export type LiveActivityAlert = R.LiveActivityAlert; + export type LiveActivityEvent = R.LiveActivityEvent; + export type LiveActivityResult = R.LiveActivityResult; + export type MemberRole = R.MemberRole; + export type Message = R.Message; + export type MessageAction = R.MessageAction; + export type MessageCounts = R.MessageCounts; + export type MessageDelivery = R.MessageDelivery; + export type MessagePayload = R.MessagePayload; + export type MessagePriority = R.MessagePriority; + export type MessageSchedule = R.MessageSchedule; + export type MessageScheduleInput = R.MessageScheduleInput; + export type MessageStatus = R.MessageStatus; + export type MessageTargets = R.MessageTargets; + export type Metadata = R.Metadata; + export type Platform = R.Platform; + export type PreferenceChanges = R.PreferenceChanges; + export type Provider = R.Provider; + export type QuietHours = R.QuietHours; + export type ResendCredentialParams = R.ResendCredentialParams; + export type Run = R.Run; + export type RunCounts = R.RunCounts; + export type RunDetail = R.RunDetail; + export type RunStatus = R.RunStatus; + export type RunTotals = R.RunTotals; + export type Secret = R.Secret; + export type Segment = R.Segment; + export type SegmentPreview = R.SegmentPreview; + export type SegmentVersion = R.SegmentVersion; + export type SendLiveActivityParams = R.SendLiveActivityParams; + export type SendMessageParams = R.SendMessageParams; + export type SendPolicy = R.SendPolicy; + export type Source = R.Source; + export type SourceDelivery = R.SourceDelivery; + export type SourceDeliveryOutcome = R.SourceDeliveryOutcome; + export type SourceMapping = R.SourceMapping; + export type SourcePreset = R.SourcePreset; + export type SourcePreview = R.SourcePreview; + export type SourcePreviewParams = R.SourcePreviewParams; + export type SourceProvider = R.SourceProvider; + export type SourceStatus = R.SourceStatus; + export type SourceVerification = R.SourceVerification; + export type Stats = R.Stats; + export type StatsDay = R.StatsDay; + export type StatsInterval = R.StatsInterval; + export type StatsParams = R.StatsParams; + export type StatsWindow = R.StatsWindow; + export type StatsWorkflow = R.StatsWorkflow; + export type Subscriber = R.Subscriber; + export type SubscriberAlias = R.SubscriberAlias; + export type SubscriberDelivery = R.SubscriberDelivery; + export type SubscriberListItem = R.SubscriberListItem; + export type SubscriberPreference = R.SubscriberPreference; + export type SubscriberTimelineParams = R.SubscriberTimelineParams; + export type SubscriberWithSubscriptions = R.SubscriberWithSubscriptions; + export type RegisteredSubscription = R.RegisteredSubscription; + export type Subscription = R.Subscription; + export type SubscriptionStatus = R.SubscriptionStatus; + export type Tenant = R.Tenant; + export type TenantSettings = R.TenantSettings; + export type TenantSettingsPatch = R.TenantSettingsPatch; + export type TestWorkflowParams = R.TestWorkflowParams; + export type Topic = R.Topic; + export type TopicCategory = R.TopicCategory; + export type TrackedEvent = R.TrackedEvent; + export type UpdateSegmentParams = R.UpdateSegmentParams; + export type UpdateSourceParams = R.UpdateSourceParams; + export type UpdateTenantParams = R.UpdateTenantParams; + export type UpdateTopicParams = R.UpdateTopicParams; + export type UpdateWebhookParams = R.UpdateWebhookParams; + export type UpdateWorkflowParams = R.UpdateWorkflowParams; + export type UpdateWorkspaceParams = R.UpdateWorkspaceParams; + export type UpsertSubscriberParams = R.UpsertSubscriberParams; + export type WebhookAttempt = R.WebhookAttempt; + export type WebhookCatalogGroup = R.WebhookCatalogGroup; + export type WebhookDelivery = R.WebhookDelivery; + export type WebhookDeliveryDetail = R.WebhookDeliveryDetail; + export type WebhookDeliveryStatus = R.WebhookDeliveryStatus; + export type WebhookEndpoint = R.WebhookEndpoint; + export type WebhookEndpointWithSecret = R.WebhookEndpointWithSecret; + export type WebhookEvent = R.WebhookEvent; + export type Workflow = R.Workflow; + export type WorkflowSchedule = R.WorkflowSchedule; + export type WorkflowScheduleFire = R.WorkflowScheduleFire; + export type WorkflowSpec = R.WorkflowSpec; + export type WorkflowStatus = R.WorkflowStatus; + export type WorkflowStepTrace = R.WorkflowStepTrace; + export type WorkflowTestResult = R.WorkflowTestResult; + export type WorkflowVersion = R.WorkflowVersion; + export type Workspace = R.Workspace; + export type WorkspaceMember = R.WorkspaceMember; +} diff --git a/packages/buzzkit/src/server/identity.ts b/packages/buzzkit/src/server/identity.ts new file mode 100644 index 00000000..064b0aaf --- /dev/null +++ b/packages/buzzkit/src/server/identity.ts @@ -0,0 +1,17 @@ +const encoder = new TextEncoder(); + +function toHex(signature: ArrayBuffer): string { + return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export async function signIdentity(externalId: string, identitySecret: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(identitySecret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + return toHex(await crypto.subtle.sign('HMAC', key, encoder.encode(externalId))); +} diff --git a/packages/buzzkit/src/server/index.ts b/packages/buzzkit/src/server/index.ts new file mode 100644 index 00000000..e10d9693 --- /dev/null +++ b/packages/buzzkit/src/server/index.ts @@ -0,0 +1,5 @@ +export { BuzzKit } from './buzzkit'; +export { signIdentity } from './identity'; +export type { ClientOptions } from './options'; +export { TenantScope, WorkspaceScope } from './scopes'; +export { SubscriberScope, type SubscriberSend, type SubscriberSubscribe } from './subscriber'; diff --git a/packages/buzzkit/src/server/options.ts b/packages/buzzkit/src/server/options.ts new file mode 100644 index 00000000..3ff0b0fe --- /dev/null +++ b/packages/buzzkit/src/server/options.ts @@ -0,0 +1,74 @@ +import { + API_KEY_VARIABLE, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT_MS, + readEnvironment, + resolveBaseUrl, + resolveFetch, +} from '../core/config'; +import { ConfigurationError } from '../core/errors'; +import { assertServerKey } from '../core/keys'; +import { Transport } from '../core/transport'; + +export type ClientOptions = { + apiKey?: string; + baseUrl?: string; + tenant?: string; + workspace?: string; + timeoutMs?: number; + maxRetries?: number; + maxRetryAfterMs?: number; + headers?: Record; + fetch?: typeof globalThis.fetch; +}; + +export type ResolvedOptions = { + apiKey: string; + baseUrl: string; + tenant: string | null; + workspace: string | null; + timeoutMs: number; + maxRetries: number; + maxRetryAfterMs?: number; + headers: Record; + fetch: typeof globalThis.fetch; +}; + +export function resolveOptions(options: ClientOptions): ResolvedOptions { + const apiKey = options.apiKey ?? readEnvironment(API_KEY_VARIABLE); + if (!apiKey) { + throw new ConfigurationError( + `Missing API key — pass { apiKey } to the BuzzKit client or set ${API_KEY_VARIABLE}` + ); + } + + assertServerKey(apiKey); + + return { + apiKey, + baseUrl: resolveBaseUrl(options.baseUrl), + tenant: options.tenant ?? null, + workspace: options.workspace ?? null, + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, + maxRetryAfterMs: options.maxRetryAfterMs, + headers: options.headers ?? {}, + fetch: resolveFetch(options.fetch), + }; +} + +export function serverTransport(options: ResolvedOptions): Transport { + return new Transport({ + baseUrl: options.baseUrl, + timeoutMs: options.timeoutMs, + maxRetries: options.maxRetries, + maxRetryAfterMs: options.maxRetryAfterMs, + fetch: options.fetch, + headers: { + ...options.headers, + authorization: `Bearer ${options.apiKey}`, + ...(options.tenant ? { 'buzzkit-tenant': options.tenant } : {}), + ...(options.workspace ? { 'buzzkit-workspace': options.workspace } : {}), + }, + }); +} diff --git a/packages/buzzkit/src/server/scopes.ts b/packages/buzzkit/src/server/scopes.ts new file mode 100644 index 00000000..632cc185 --- /dev/null +++ b/packages/buzzkit/src/server/scopes.ts @@ -0,0 +1,134 @@ +import type { PagePromise } from '../core/pagination'; +import type { Transport } from '../core/transport'; +import type { CredentialsResource } from '../resources/credentials'; +import { credentialsResource } from '../resources/credentials'; +import type { DeliveriesResource } from '../resources/deliveries'; +import { deliveriesResource } from '../resources/deliveries'; +import type { EventInput, EventsResource, TrackedEvent } from '../resources/events'; +import { eventsResource } from '../resources/events'; +import type { ImportsResource } from '../resources/imports'; +import { importsResource } from '../resources/imports'; +import type { LiveActivitiesResource } from '../resources/liveActivities'; +import { liveActivitiesResource } from '../resources/liveActivities'; +import type { Message, MessagesResource, SendMessageParams } from '../resources/messages'; +import { messagesResource } from '../resources/messages'; +import type { RunsResource } from '../resources/runs'; +import { runsResource } from '../resources/runs'; +import type { SecretsResource } from '../resources/secrets'; +import { secretsResource } from '../resources/secrets'; +import type { SegmentsResource } from '../resources/segments'; +import { segmentsResource } from '../resources/segments'; +import type { SourcesResource } from '../resources/sources'; +import { sourcesResource } from '../resources/sources'; +import type { StatsResource } from '../resources/stats'; +import { statsResource } from '../resources/stats'; +import type { Subscriber, SubscribersResource, UpsertSubscriberParams } from '../resources/subscribers'; +import { subscribersResource } from '../resources/subscribers'; +import type { SubscriptionsResource } from '../resources/subscriptions'; +import { subscriptionsResource } from '../resources/subscriptions'; +import type { TopicCategoriesResource, TopicsResource } from '../resources/topics'; +import { topicCategoriesResource, topicsResource } from '../resources/topics'; +import type { WebhooksResource } from '../resources/webhooks'; +import { webhooksResource } from '../resources/webhooks'; +import type { WorkflowsResource } from '../resources/workflows'; +import { workflowsResource } from '../resources/workflows'; +import type { + AuditResource, + MembersResource, + UpdateWorkspaceParams, + Workspace, + WorkspacesResource, +} from '../resources/workspaces'; +import { auditResource, membersResource, workspacesResource } from '../resources/workspaces'; +import { SubscriberScope } from './subscriber'; + +export class TenantScope { + readonly messages: MessagesResource; + readonly subscribers: SubscribersResource; + readonly subscriptions: SubscriptionsResource; + readonly topics: TopicsResource; + readonly topicCategories: TopicCategoriesResource; + readonly segments: SegmentsResource; + readonly workflows: WorkflowsResource; + readonly runs: RunsResource; + readonly events: EventsResource; + readonly deliveries: DeliveriesResource; + readonly credentials: CredentialsResource; + readonly secrets: SecretsResource; + readonly sources: SourcesResource; + readonly imports: ImportsResource; + readonly liveActivities: LiveActivitiesResource; + readonly stats: StatsResource; + + protected readonly transport: Transport; + + constructor(transport: Transport) { + this.transport = transport; + this.messages = messagesResource(transport); + this.subscribers = subscribersResource(transport); + this.subscriptions = subscriptionsResource(transport); + this.topics = topicsResource(transport); + this.topicCategories = topicCategoriesResource(transport); + this.segments = segmentsResource(transport); + this.workflows = workflowsResource(transport); + this.runs = runsResource(transport); + this.events = eventsResource(transport); + this.deliveries = deliveriesResource(transport); + this.credentials = credentialsResource(transport); + this.secrets = secretsResource(transport); + this.sources = sourcesResource(transport); + this.imports = importsResource(transport); + this.liveActivities = liveActivitiesResource(transport); + this.stats = statsResource(transport); + } + + send(params: SendMessageParams): Promise { + return this.messages.send(params); + } + + track(events: EventInput | EventInput[]): PagePromise { + return this.events.track(events); + } + + subscriber(externalId: string): SubscriberScope { + return new SubscriberScope(this.scopeResources(), externalId, null); + } + + identify(externalId: string, params: UpsertSubscriberParams = {}): Promise> { + return this.subscriber(externalId).identify(params); + } + + private scopeResources() { + return { + subscribers: this.subscribers, + subscriptions: this.subscriptions, + messages: this.messages, + events: this.events, + }; + } +} + +export class WorkspaceScope { + readonly webhooks: WebhooksResource; + readonly members: MembersResource; + readonly audit: AuditResource; + + private readonly workspaces: WorkspacesResource; + private readonly slug: string; + + constructor(transport: Transport, slug: string) { + this.slug = slug; + this.workspaces = workspacesResource(transport); + this.webhooks = webhooksResource(transport, slug); + this.members = membersResource(transport, slug); + this.audit = auditResource(transport, slug); + } + + retrieve(): Promise { + return this.workspaces.retrieve(this.slug); + } + + update(params: UpdateWorkspaceParams): Promise { + return this.workspaces.update(this.slug, params); + } +} diff --git a/packages/buzzkit/src/server/subscriber.ts b/packages/buzzkit/src/server/subscriber.ts new file mode 100644 index 00000000..16be4455 --- /dev/null +++ b/packages/buzzkit/src/server/subscriber.ts @@ -0,0 +1,104 @@ +import { BuzzKitError } from '../core/errors'; +import type { PageParams, PagePromise } from '../core/pagination'; +import type { Deleted } from '../resources/common'; +import type { EventRecord, EventsResource, TrackedEvent } from '../resources/events'; +import type { Message, MessagesResource, SendMessageParams } from '../resources/messages'; +import type { Run } from '../resources/runs'; +import type { + PreferenceChanges, + Subscriber, + SubscriberDelivery, + SubscribersResource, + SubscriberTimelineParams, + SubscriberWithSubscriptions, + UpsertSubscriberParams, +} from '../resources/subscribers'; +import type { + CreateSubscriptionParams, + Subscription, + SubscriptionsResource, +} from '../resources/subscriptions'; +import type { SubscriberPreference } from '../resources/topics'; + +export type SubscriberSend = Omit; + +export type SubscriberSubscribe = Omit; + +type Resources = { + subscribers: SubscribersResource; + subscriptions: SubscriptionsResource; + messages: MessagesResource; + events: EventsResource; +}; + +export class SubscriberScope { + readonly externalId: string; + readonly data: TData; + + private readonly resources: Resources; + + constructor(resources: Resources, externalId: string, data: TData) { + this.resources = resources; + this.externalId = externalId; + this.data = data; + } + + async identify(params: UpsertSubscriberParams = {}): Promise> { + const subscriber = await this.resources.subscribers.upsert(this.externalId, params); + return new SubscriberScope(this.resources, this.externalId, subscriber); + } + + retrieve(): Promise { + return this.resources.subscribers.retrieve(this.externalId); + } + + remove(): Promise> { + return this.resources.subscribers.remove(this.externalId); + } + + send(params: SubscriberSend): Promise { + return this.resources.messages.send({ ...params, to: this.externalId }); + } + + async track(name: string, data?: Record): Promise { + const page = await this.resources.events.track({ externalId: this.externalId, name, data }); + + const [tracked] = page.items; + if (!tracked) { + throw new BuzzKitError(`The BuzzKit API accepted no event for '${name}'`, { + status: null, + code: 'event_not_tracked', + }); + } + + return tracked; + } + + subscribe(params: SubscriberSubscribe): Promise { + return this.resources.subscriptions.create({ ...params, externalId: this.externalId }); + } + + subscriptions(): PagePromise { + return this.resources.subscribers.subscriptions(this.externalId); + } + + preferences(): PagePromise { + return this.resources.subscribers.preferences(this.externalId); + } + + updatePreferences(changes: PreferenceChanges): PagePromise { + return this.resources.subscribers.updatePreferences(this.externalId, changes); + } + + deliveries(params: PageParams = {}): PagePromise { + return this.resources.subscribers.deliveries(this.externalId, params); + } + + timeline(params: SubscriberTimelineParams = {}): PagePromise { + return this.resources.subscribers.timeline(this.externalId, params); + } + + runs(): PagePromise { + return this.resources.subscribers.runs(this.externalId); + } +} diff --git a/packages/schema/src/sources/constants.ts b/packages/buzzkit/src/sources/constants.ts similarity index 91% rename from packages/schema/src/sources/constants.ts rename to packages/buzzkit/src/sources/constants.ts index 51678262..a0b80c12 100644 --- a/packages/schema/src/sources/constants.ts +++ b/packages/buzzkit/src/sources/constants.ts @@ -2,8 +2,6 @@ export const SOURCE_PROVIDERS = ['stripe', 'superwall', 'revenuecat', 'custom'] export const SOURCE_STATUSES = ['unverified', 'active', 'paused'] as const; -export const DELIVERY_OUTCOMES = ['event', 'duplicate', 'dropped', 'rejected', 'unverified'] as const; - export const DROP_REASONS = [ 'no_type', 'unlisted_type', diff --git a/packages/buzzkit/src/sources/index.ts b/packages/buzzkit/src/sources/index.ts new file mode 100644 index 00000000..72d20bcb --- /dev/null +++ b/packages/buzzkit/src/sources/index.ts @@ -0,0 +1,3 @@ +export { SOURCE_DELIVERY_OUTCOMES } from '../resources/common'; +export * from './constants'; +export type * from './types'; diff --git a/packages/schema/src/sources/types.ts b/packages/buzzkit/src/sources/types.ts similarity index 84% rename from packages/schema/src/sources/types.ts rename to packages/buzzkit/src/sources/types.ts index 7066cd47..e1410428 100644 --- a/packages/schema/src/sources/types.ts +++ b/packages/buzzkit/src/sources/types.ts @@ -1,11 +1,12 @@ -import type { Expression } from 'buzzkit/expressions'; -import type { DELIVERY_OUTCOMES, DROP_REASONS, SOURCE_PROVIDERS, SOURCE_STATUSES } from './constants'; +import type { Expression } from '../expressions/index'; +import type { SOURCE_DELIVERY_OUTCOMES } from '../resources/common'; +import type { DROP_REASONS, SOURCE_PROVIDERS, SOURCE_STATUSES } from './constants'; export type SourceProvider = (typeof SOURCE_PROVIDERS)[number]; export type SourceStatus = (typeof SOURCE_STATUSES)[number]; -export type DeliveryOutcome = (typeof DELIVERY_OUTCOMES)[number]; +export type DeliveryOutcome = (typeof SOURCE_DELIVERY_OUTCOMES)[number]; export type DropReason = (typeof DROP_REASONS)[number]; diff --git a/packages/buzzkit/src/workflows/constants.ts b/packages/buzzkit/src/workflows/constants.ts new file mode 100644 index 00000000..bdd895f3 --- /dev/null +++ b/packages/buzzkit/src/workflows/constants.ts @@ -0,0 +1,67 @@ +export const CONCURRENCY_MODES = ['per-event', 'one-per-subscriber'] as const; + +export const TRIGGER_SOURCES = ['server', 'ios', 'android', 'web', 'system', 'webhook'] as const; + +export const SEND_CHANNELS = ['push'] as const; + +export const DELIVERY_MODES = ['push', 'local'] as const; + +export const STEP_KINDS = [ + 'wait', + 'waitUntil', + 'waitFor', + 'repeat', + 'forEach', + 'branch', + 'fetch', + 'set', + 'send', + 'exit', +] as const; + +export const SINCE_ANCHORS = ['trigger', 'localMidnight', 'iteration'] as const; + +export const INTERRUPTION_LEVELS = ['passive', 'active', 'timeSensitive', 'critical'] as const; + +export const SEND_PRIORITIES = ['high', 'normal'] as const; + +export const SEND_POLICY_MODES = ['ignore'] as const; + +export const FETCH_ERROR_MODES = ['fail', 'skip', 'continue'] as const; + +export const FETCH_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; + +export const TEMPLATE_FILTERS = [ + 'default', + 'upcase', + 'downcase', + 'capitalize', + 'strip', + 'truncate', + 'append', + 'prepend', + 'replace', + 'pluralize', + 'size', + 'first', + 'last', + 'join', + 'url_encode', + 'json', + 'number', + 'round', + 'ceil', + 'floor', + 'abs', + 'plus', + 'minus', + 'times', + 'divided_by', + 'modulo', + 'at_least', + 'at_most', + 'date', + 'time', + 'until', + 'ago', +] as const; diff --git a/packages/buzzkit/src/workflows/index.ts b/packages/buzzkit/src/workflows/index.ts new file mode 100644 index 00000000..c2916dc6 --- /dev/null +++ b/packages/buzzkit/src/workflows/index.ts @@ -0,0 +1,2 @@ +export * from './constants'; +export type * from './types'; diff --git a/packages/schema/src/workflows/types.ts b/packages/buzzkit/src/workflows/types.ts similarity index 99% rename from packages/schema/src/workflows/types.ts rename to packages/buzzkit/src/workflows/types.ts index b06755a8..d9503eaa 100644 --- a/packages/schema/src/workflows/types.ts +++ b/packages/buzzkit/src/workflows/types.ts @@ -1,4 +1,4 @@ -import type { CountCondition, Duration, NeverCondition, RefCondition, Scalar } from 'buzzkit/expressions'; +import type { CountCondition, Duration, NeverCondition, RefCondition, Scalar } from '../expressions/index'; import type { CONCURRENCY_MODES, DELIVERY_MODES, diff --git a/packages/buzzkit/test/client/buzzkit.test.ts b/packages/buzzkit/test/client/buzzkit.test.ts new file mode 100644 index 00000000..b7401d08 --- /dev/null +++ b/packages/buzzkit/test/client/buzzkit.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest'; +import { BuzzKitClient } from '../../src/client/buzzkit'; +import { ConfigurationError } from '../../src/core/errors'; +import { envelope, page, type Stub, stub } from '../utils/stub'; + +const identity = { externalId: 'user_123', identityHash: 'deadbeef' }; + +const preference = { + id: 'tpc_1', + slug: 'product-updates', + name: 'Product updates', + description: null, + category: null, + channels: { push: { optedIn: true, isDefault: false } }, +}; + +function client(source: Stub, overrides: { identity?: typeof identity } = {}) { + return new BuzzKitClient({ + publishableKey: 'bk_pk_public', + baseUrl: 'https://api.test', + fetch: source.fetch, + identity, + ...overrides, + }); +} + +function anonymous(source: Stub) { + return new BuzzKitClient({ + publishableKey: 'bk_pk_public', + baseUrl: 'https://api.test', + fetch: source.fetch, + }); +} + +describe('BuzzKitClient construction', () => { + it('requires a publishable key', () => { + expect(() => new BuzzKitClient({ publishableKey: '' })).toThrow(ConfigurationError); + }); + + it('refuses a secret key', () => { + for (const key of ['bk_ws_secret', 'bk_tn_secret']) { + expect(() => new BuzzKitClient({ publishableKey: key }), key).toThrow(ConfigurationError); + } + }); + + it('exposes the identity it was built with', () => { + expect(client(stub([])).identity).toEqual(identity); + expect(anonymous(stub([])).identity).toBeNull(); + }); +}); + +describe('BuzzKitClient.as', () => { + it('returns a client for another subscriber, leaving the original alone', async () => { + const source = stub([page([preference]), page([preference])]); + const base = client(source); + + const other = base.as({ externalId: 'user_999' }); + await other.preferences(); + await base.preferences(); + + expect(other.identity?.externalId).toBe('user_999'); + expect(source.calls[0]?.headers['buzzkit-subscriber']).toBe('user_999'); + expect(source.calls[1]?.headers['buzzkit-subscriber']).toBe('user_123'); + }); + + it('identifies a client that had none', async () => { + const source = stub([page([preference])]); + + await anonymous(source).as({ externalId: 'user_5' }).preferences(); + + expect(source.calls[0]?.headers['buzzkit-subscriber']).toBe('user_5'); + }); +}); + +describe('BuzzKitClient auth headers', () => { + it('sends the publishable key and the identity headers', async () => { + const source = stub([page([preference])]); + + await client(source).preferences(); + + expect(source.calls[0]?.headers).toMatchObject({ + authorization: 'Bearer bk_pk_public', + 'buzzkit-subscriber': 'user_123', + 'buzzkit-identity': 'deadbeef', + }); + }); + + it('omits the identity hash when the tenant does not require verification', async () => { + const source = stub([page([preference])]); + + await anonymous(source).as({ externalId: 'user_5' }).preferences(); + + expect(source.calls[0]?.headers['buzzkit-identity']).toBeUndefined(); + }); +}); + +describe('BuzzKitClient without an identity', () => { + it('refuses every call rather than reaching the API', async () => { + const source = stub([]); + const target = anonymous(source); + + await expect(target.preferences()).rejects.toBeInstanceOf(ConfigurationError); + await expect(target.updatePreferences({})).rejects.toBeInstanceOf(ConfigurationError); + await expect(target.identify()).rejects.toBeInstanceOf(ConfigurationError); + await expect(target.track('a')).rejects.toBeInstanceOf(ConfigurationError); + await expect(target.subscribeEmail({ address: 'a@b.c' })).rejects.toBeInstanceOf(ConfigurationError); + await expect(target.updateSubscription('sbn_1', true)).rejects.toBeInstanceOf(ConfigurationError); + await expect(target.removeSubscription('sbn_1')).rejects.toBeInstanceOf(ConfigurationError); + expect(source.calls).toHaveLength(0); + }); +}); + +describe('BuzzKitClient.identify', () => { + it('posts to the client route with the identity in the body', async () => { + const source = stub([envelope({ id: 'sbr_1', externalId: 'user_123' })]); + + await client(source).identify({ email: 'ada@acme.com', attributes: { plan: 'pro' } }); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/client/identify'); + expect(source.calls[0]?.body).toEqual({ + email: 'ada@acme.com', + attributes: { plan: 'pro' }, + externalId: 'user_123', + identityHash: 'deadbeef', + }); + }); + + it('carries an anonymous id for the merge', async () => { + const source = stub([envelope({ id: 'sbr_1' })]); + + await client(source).identify({ anonymousId: 'anon_1' }); + + expect(source.calls[0]?.body).toMatchObject({ anonymousId: 'anon_1' }); + }); +}); + +describe('BuzzKitClient.preferences', () => { + it('unwraps the list', async () => { + const source = stub([page([preference])]); + + await expect(client(source).preferences()).resolves.toEqual([preference]); + expect(source.calls[0]?.method).toBe('GET'); + }); + + it('patches and returns the new list', async () => { + const updated = { ...preference, channels: { push: { optedIn: false, isDefault: false } } }; + const source = stub([page([updated])]); + + await expect(client(source).updatePreferences({ 'product-updates': { push: false } })).resolves.toEqual([ + updated, + ]); + expect(source.calls[0]?.method).toBe('PATCH'); + expect(source.calls[0]?.body).toEqual({ preferences: { 'product-updates': { push: false } } }); + }); +}); + +describe('BuzzKitClient.track', () => { + it('sends a web-sourced event and unwraps it', async () => { + const tracked = { id: 'evt_1', name: 'pricing.viewed', status: 'accepted' }; + const source = stub([page([tracked])]); + + await expect(client(source).track('pricing.viewed', { plan: 'pro' })).resolves.toEqual(tracked); + expect(source.calls[0]?.url).toBe('https://api.test/v1/client/events'); + expect(source.calls[0]?.body).toEqual({ + externalId: 'user_123', + identityHash: 'deadbeef', + source: 'web', + events: [{ name: 'pricing.viewed', data: { plan: 'pro' } }], + }); + }); + + it('fails loudly when the API accepts no event', async () => { + const source = stub([page([])]); + + await expect(client(source).track('pricing.viewed')).rejects.toBeInstanceOf(ConfigurationError); + }); +}); + +describe('BuzzKitClient subscriptions', () => { + it('registers an email address', async () => { + const source = stub([envelope({ id: 'sbn_1', channel: 'email' })]); + + await client(source).subscribeEmail({ address: 'ada@acme.com' }); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/client/subscriptions'); + expect(source.calls[0]?.body).toEqual({ + externalId: 'user_123', + identityHash: 'deadbeef', + channel: 'email', + address: 'ada@acme.com', + }); + }); + + it('mutes and removes one by id', async () => { + const source = stub([ + envelope({ id: 'sbn_1', enabled: false }), + envelope({ id: 'sbn_1', deleted: true }), + ]); + const target = client(source); + + await target.updateSubscription('sbn_1', false); + await target.removeSubscription('sbn_1'); + + expect(source.calls[0]?.method).toBe('PATCH'); + expect(source.calls[0]?.body).toEqual({ enabled: false }); + expect(source.calls[1]?.method).toBe('DELETE'); + expect(source.calls[1]?.url).toBe('https://api.test/v1/client/subscriptions/sbn_1'); + }); +}); diff --git a/packages/buzzkit/test/core/config.test.ts b/packages/buzzkit/test/core/config.test.ts new file mode 100644 index 00000000..2f61982a --- /dev/null +++ b/packages/buzzkit/test/core/config.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_BASE_URL, resolveBaseUrl, resolveFetch } from '../../src/core/config'; +import { ConfigurationError } from '../../src/core/errors'; + +describe('resolveBaseUrl', () => { + it('defaults to BuzzKit Cloud', () => { + expect(resolveBaseUrl(undefined)).toBe(DEFAULT_BASE_URL); + }); + + it('trims a trailing slash so paths never double up', () => { + expect(resolveBaseUrl('https://buzzkit.internal/')).toBe('https://buzzkit.internal'); + expect(resolveBaseUrl('https://buzzkit.internal')).toBe('https://buzzkit.internal'); + }); + + it('keeps a base path', () => { + expect(resolveBaseUrl('https://gateway.test/buzzkit/')).toBe('https://gateway.test/buzzkit'); + }); +}); + +describe('resolveFetch', () => { + it('falls back to the platform fetch', () => { + expect(resolveFetch(undefined)).toBeTypeOf('function'); + }); + + it('prefers the injected fetch', () => { + const injected = (async () => new Response('{}')) as unknown as typeof globalThis.fetch; + + expect(resolveFetch(injected)).toBeTypeOf('function'); + }); + + it('refuses a runtime with no usable fetch', () => { + expect(() => resolveFetch('nope' as unknown as typeof globalThis.fetch)).toThrow(ConfigurationError); + }); +}); diff --git a/packages/buzzkit/test/core/errors.test.ts b/packages/buzzkit/test/core/errors.test.ts new file mode 100644 index 00000000..c8a40a71 --- /dev/null +++ b/packages/buzzkit/test/core/errors.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { + AuthenticationError, + BadRequestError, + BuzzKitError, + ConfigurationError, + ConflictError, + ConnectionError, + isBuzzKitError, + NotFoundError, + PermissionError, + RateLimitError, + resolveError, + ServerError, + TimeoutError, +} from '../../src/core/errors'; + +const body = { code: 'not_found', message: 'Message not found', param: 'id' }; + +describe('resolveError', () => { + it('maps a status onto its error class', () => { + const cases = [ + [400, BadRequestError], + [422, BadRequestError], + [401, AuthenticationError], + [403, PermissionError], + [404, NotFoundError], + [409, ConflictError], + [410, ConflictError], + [429, RateLimitError], + [500, ServerError], + [503, ServerError], + ] as const; + + for (const [status, expected] of cases) { + expect(resolveError(status, body, {}), String(status)).toBeInstanceOf(expected); + } + }); + + it('falls back to the base error for an unmapped status', () => { + const error = resolveError(418, body, {}); + + expect(error.constructor).toBe(BuzzKitError); + expect(error.name).toBe('BuzzKitError'); + }); + + it('carries the API error detail through', () => { + const error = resolveError(404, body, { requestId: 'req_1' }); + + expect(error.message).toBe('Message not found'); + expect(error.code).toBe('not_found'); + expect(error.param).toBe('id'); + expect(error.status).toBe(404); + expect(error.requestId).toBe('req_1'); + }); + + it('carries validation details', () => { + const details = [{ param: 'to', message: 'Required' }]; + const error = resolveError(400, { code: 'validation', message: 'Invalid', details }, {}); + + expect(error.details).toEqual(details); + }); + + it('exposes the retry delay on a rate limit', () => { + const error = resolveError( + 429, + { code: 'rate_limited', message: 'Slow down' }, + { + retryAfterSeconds: 7, + } + ); + + expect(error).toBeInstanceOf(RateLimitError); + expect((error as RateLimitError).retryAfterSeconds).toBe(7); + }); +}); + +describe('error classes', () => { + it('names every class after itself so stacks read correctly', () => { + const named: Array<[BuzzKitError, string]> = [ + [new BadRequestError('x', { status: 400, code: 'bad_request' }), 'BadRequestError'], + [new AuthenticationError('x', { status: 401, code: 'unauthorized' }), 'AuthenticationError'], + [new PermissionError('x', { status: 403, code: 'forbidden' }), 'PermissionError'], + [new NotFoundError('x', { status: 404, code: 'not_found' }), 'NotFoundError'], + [new ConflictError('x', { status: 409, code: 'conflict' }), 'ConflictError'], + [new RateLimitError('x', { status: 429, code: 'rate_limited' }), 'RateLimitError'], + [new ServerError('x', { status: 500, code: 'internal' }), 'ServerError'], + [new ConnectionError('x'), 'ConnectionError'], + [new TimeoutError('x'), 'TimeoutError'], + [new ConfigurationError('x'), 'ConfigurationError'], + ]; + + for (const [error, name] of named) { + expect(error.name, name).toBe(name); + expect(error, name).toBeInstanceOf(BuzzKitError); + expect(error, name).toBeInstanceOf(Error); + } + }); + + it('gives transport failures no status and a stable code', () => { + const connection = new ConnectionError('offline'); + const timeout = new TimeoutError('too slow'); + + expect(connection.status).toBeNull(); + expect(connection.code).toBe('connection'); + expect(timeout.status).toBeNull(); + expect(timeout.code).toBe('timeout'); + expect(timeout).toBeInstanceOf(ConnectionError); + }); + + it('keeps the cause of a connection failure', () => { + const cause = new Error('ECONNREFUSED'); + expect(new ConnectionError('offline', { cause }).cause).toBe(cause); + }); +}); + +describe('isBuzzKitError', () => { + it('recognizes every BuzzKit error', () => { + expect(isBuzzKitError(new NotFoundError('x', { status: 404, code: 'not_found' }))).toBe(true); + expect(isBuzzKitError(new ConfigurationError('x'))).toBe(true); + }); + + it('rejects anything else', () => { + expect(isBuzzKitError(new Error('plain'))).toBe(false); + expect(isBuzzKitError('not_found')).toBe(false); + expect(isBuzzKitError(null)).toBe(false); + }); +}); diff --git a/packages/buzzkit/test/core/keys.test.ts b/packages/buzzkit/test/core/keys.test.ts new file mode 100644 index 00000000..07bec65f --- /dev/null +++ b/packages/buzzkit/test/core/keys.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigurationError } from '../../src/core/errors'; +import { assertClientKey, assertServerKey } from '../../src/core/keys'; + +describe('assertServerKey', () => { + it('accepts workspace and tenant keys', () => { + expect(() => assertServerKey('bk_ws_live_abc')).not.toThrow(); + expect(() => assertServerKey('bk_tn_live_abc')).not.toThrow(); + }); + + it('refuses a client key and points at the right entry', () => { + expect(() => assertServerKey('bk_pk_live_abc')).toThrow(ConfigurationError); + expect(() => assertServerKey('bk_pk_live_abc')).toThrow(/buzzkit\/client/); + }); + + it('leaves an unrecognized key to the API to reject', () => { + expect(() => assertServerKey('session-token')).not.toThrow(); + }); +}); + +describe('assertClientKey', () => { + it('accepts a client key', () => { + expect(() => assertClientKey('bk_pk_live_abc')).not.toThrow(); + }); + + it('refuses the two secret key kinds', () => { + for (const key of ['bk_ws_live_abc', 'bk_tn_live_abc']) { + expect(() => assertClientKey(key), key).toThrow(ConfigurationError); + expect(() => assertClientKey(key), key).toThrow(/never reach a browser/); + } + }); + + it('leaves an unrecognized key to the API to reject', () => { + expect(() => assertClientKey('session-token')).not.toThrow(); + }); +}); diff --git a/packages/buzzkit/test/core/pagination.test.ts b/packages/buzzkit/test/core/pagination.test.ts new file mode 100644 index 00000000..0e85b090 --- /dev/null +++ b/packages/buzzkit/test/core/pagination.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Page, PageParams } from '../../src/core/pagination'; +import { paginate } from '../../src/core/pagination'; + +type Row = { id: string }; + +function pagesOf(...batches: Array<{ items: Row[]; nextCursor: string | null }>) { + const load = vi.fn(async (params: PageParams & { limit?: number }): Promise> => { + const index = params.cursor + ? batches.findIndex((candidate) => candidate.nextCursor === params.cursor) + 1 + : 0; + const batch = batches[index]; + if (!batch) throw new Error(`No page for cursor ${String(params.cursor)}`); + + return { items: batch.items, hasMore: batch.nextCursor !== null, nextCursor: batch.nextCursor }; + }); + + return load; +} + +describe('paginate', () => { + it('resolves to the first page when awaited', async () => { + const load = pagesOf({ items: [{ id: 'a' }], nextCursor: null }); + + const page = await paginate(load, { limit: 10 }); + + expect(page.items).toEqual([{ id: 'a' }]); + expect(page.hasMore).toBe(false); + expect(load).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledWith({ limit: 10 }); + }); + + it('walks every page when iterated', async () => { + const load = pagesOf( + { items: [{ id: 'a' }, { id: 'b' }], nextCursor: 'cur_1' }, + { items: [{ id: 'c' }], nextCursor: 'cur_2' }, + { items: [{ id: 'd' }], nextCursor: null } + ); + + const seen: string[] = []; + for await (const row of paginate(load, { limit: 2 })) seen.push(row.id); + + expect(seen).toEqual(['a', 'b', 'c', 'd']); + expect(load).toHaveBeenNthCalledWith(2, { limit: 2, cursor: 'cur_1' }); + expect(load).toHaveBeenNthCalledWith(3, { limit: 2, cursor: 'cur_2' }); + }); + + it('fetches the first page once however it is consumed', async () => { + const load = pagesOf({ items: [{ id: 'a' }], nextCursor: null }); + const pending = paginate(load, {}); + + const first = await pending; + const seen: string[] = []; + for await (const row of pending) seen.push(row.id); + + expect(first.items).toEqual([{ id: 'a' }]); + expect(seen).toEqual(['a']); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('stops at a page that claims more but carries no cursor', async () => { + const load = vi.fn(async (): Promise> => { + return { items: [{ id: 'a' }], hasMore: true, nextCursor: null }; + }); + + const seen: string[] = []; + for await (const row of paginate(load, {})) seen.push(row.id); + + expect(seen).toEqual(['a']); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('yields nothing for an empty page', async () => { + const load = pagesOf({ items: [], nextCursor: null }); + + const seen: Row[] = []; + for await (const row of paginate(load, {})) seen.push(row); + + expect(seen).toEqual([]); + }); + + it('rejects rather than hanging when the page fails', async () => { + const load = vi.fn(async (): Promise> => { + throw new Error('boom'); + }); + + await expect(paginate(load, {})).rejects.toThrow('boom'); + }); + + it('is catchable and finally-able like a promise', async () => { + const load = vi.fn(async (): Promise> => { + throw new Error('boom'); + }); + const settled = vi.fn(); + + const caught = await paginate(load, {}) + .catch((error: Error) => error.message) + .finally(settled); + + expect(caught).toBe('boom'); + expect(settled).toHaveBeenCalled(); + }); +}); + +describe('PagePromise shape', () => { + it('runs finally directly on the page promise', async () => { + const load = pagesOf({ items: [{ id: 'a' }], nextCursor: null }); + const settled = vi.fn(); + + await paginate(load, {}).finally(settled); + + expect(settled).toHaveBeenCalledTimes(1); + }); + + it('identifies itself for debugging', () => { + const load = pagesOf({ items: [], nextCursor: null }); + + expect(Object.prototype.toString.call(paginate(load, {}))).toBe('[object PagePromise]'); + }); +}); diff --git a/packages/buzzkit/test/core/retry.test.ts b/packages/buzzkit/test/core/retry.test.ts new file mode 100644 index 00000000..fa8498c1 --- /dev/null +++ b/packages/buzzkit/test/core/retry.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_MAX_RETRY_AFTER_MS, + isRetryableStatus, + nextRetryDelayMs, + parseRetryAfter, + RETRY_POLICY, +} from '../../src/core/retry'; + +const policy = { ...RETRY_POLICY, maxRetries: 2, maxRetryAfterMs: DEFAULT_MAX_RETRY_AFTER_MS }; + +describe('isRetryableStatus', () => { + it('retries the transient statuses', () => { + for (const status of [408, 429, 500, 502, 503, 504]) { + expect(isRetryableStatus(status), String(status)).toBe(true); + } + }); + + it('never retries a client mistake', () => { + for (const status of [200, 201, 400, 401, 403, 404, 409, 410, 422]) { + expect(isRetryableStatus(status), String(status)).toBe(false); + } + }); +}); + +describe('parseRetryAfter', () => { + it('reads a delay in seconds', () => { + expect(parseRetryAfter('12')).toBe(12); + expect(parseRetryAfter('0')).toBe(0); + }); + + it('reads an HTTP date as seconds from now', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + expect(parseRetryAfter(new Date('2026-01-01T00:00:30.000Z').toUTCString())).toBe(30); + + vi.useRealTimers(); + }); + + it('never returns a negative delay for a date in the past', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:01:00.000Z')); + + expect(parseRetryAfter(new Date('2026-01-01T00:00:00.000Z').toUTCString())).toBe(0); + + vi.useRealTimers(); + }); + + it('ignores a missing or unparseable header', () => { + expect(parseRetryAfter(null)).toBeUndefined(); + expect(parseRetryAfter('soon')).toBeUndefined(); + expect(parseRetryAfter('-5')).toBeUndefined(); + }); +}); + +describe('nextRetryDelayMs', () => { + it('honors Retry-After over the backoff curve', () => { + expect(nextRetryDelayMs(policy, 0, 3)).toBe(3000); + }); + + it('never shortens Retry-After to the backoff ceiling', () => { + expect(nextRetryDelayMs(policy, 0, 30)).toBeGreaterThan(policy.maxDelayMs); + }); + + it('grows exponentially and stays inside the jitter band', () => { + for (const attempt of [0, 1, 2, 3]) { + const expected = Math.min(policy.initialDelayMs * 2 ** attempt, policy.maxDelayMs); + const delay = nextRetryDelayMs(policy, attempt); + + expect(delay, `attempt ${attempt}`).toBeGreaterThanOrEqual(Math.round(expected * 0.5)); + expect(delay, `attempt ${attempt}`).toBeLessThanOrEqual(expected); + } + }); + + it('never exceeds the ceiling however many attempts have been made', () => { + expect(nextRetryDelayMs(policy, 40)).toBeLessThanOrEqual(policy.maxDelayMs); + }); + + it('applies jitter rather than a fixed delay', () => { + const delays = new Set(Array.from({ length: 50 }, () => nextRetryDelayMs(policy, 3))); + expect(delays.size).toBeGreaterThan(1); + }); + + it('waits exactly as long as the server asked, not the backoff ceiling', () => { + expect(nextRetryDelayMs(policy, 1, 30)).toBe(30_000); + expect(nextRetryDelayMs(policy, 1, 60)).toBe(60_000); + }); + + it('refuses to retry when the server asks for longer than a request should wait', () => { + expect(nextRetryDelayMs(policy, 1, 61)).toBeNull(); + expect(nextRetryDelayMs(policy, 1, 3_600)).toBeNull(); + }); + + it('lets a caller that can afford to wait honor a longer directive', () => { + const patient = { ...policy, maxRetryAfterMs: 10 * 60_000 }; + + expect(nextRetryDelayMs(patient, 1, 300)).toBe(300_000); + expect(nextRetryDelayMs(patient, 1, 601)).toBeNull(); + }); +}); diff --git a/packages/buzzkit/test/core/transport.test.ts b/packages/buzzkit/test/core/transport.test.ts new file mode 100644 index 00000000..f67c19c6 --- /dev/null +++ b/packages/buzzkit/test/core/transport.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + type BuzzKitError, + ConnectionError, + NotFoundError, + RateLimitError, + ServerError, + TimeoutError, +} from '../../src/core/errors'; +import { encodeSegment, randomIdempotencyKey, Transport } from '../../src/core/transport'; +import { envelope, failure, type Stub, stub } from '../utils/stub'; + +function transportFor(source: Stub, overrides: { maxRetries?: number; timeoutMs?: number } = {}) { + return new Transport({ + baseUrl: 'https://api.test', + timeoutMs: overrides.timeoutMs ?? 30_000, + maxRetries: overrides.maxRetries ?? 0, + headers: { authorization: 'Bearer bk_ws_key' }, + fetch: source.fetch, + }); +} + +describe('encodeSegment', () => { + it('escapes anything that would change the path', () => { + expect(encodeSegment('user/1')).toBe('user%2F1'); + expect(encodeSegment('a b')).toBe('a%20b'); + expect(encodeSegment('user?x=1#y')).toBe('user%3Fx%3D1%23y'); + expect(encodeSegment('ada@acme.com')).toBe('ada%40acme.com'); + }); +}); + +describe('randomIdempotencyKey', () => { + it('is unique per call', () => { + const keys = new Set(Array.from({ length: 100 }, randomIdempotencyKey)); + expect(keys.size).toBe(100); + }); + + it('stays unique on a runtime without crypto.randomUUID', () => { + const original = globalThis.crypto; + Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true }); + + try { + const keys = new Set(Array.from({ length: 100 }, randomIdempotencyKey)); + expect(keys.size).toBeGreaterThan(90); + } finally { + Object.defineProperty(globalThis, 'crypto', { value: original, configurable: true }); + } + }); +}); + +describe('Transport.request', () => { + it('unwraps the envelope', async () => { + const source = stub([envelope({ id: 'msg_1' })]); + + await expect( + transportFor(source).request({ method: 'GET', path: '/v1/messages/msg_1' }) + ).resolves.toEqual({ id: 'msg_1' }); + }); + + it('sends the configured headers and accepts JSON', async () => { + const source = stub([envelope({})]); + + await transportFor(source).request({ method: 'GET', path: '/v1/health' }); + + expect(source.calls[0]?.headers).toMatchObject({ + accept: 'application/json', + authorization: 'Bearer bk_ws_key', + }); + }); + + it('adds a content type only when there is a body', async () => { + const source = stub([envelope({}), envelope({})]); + const transport = transportFor(source); + + await transport.request({ method: 'GET', path: '/v1/health' }); + await transport.request({ method: 'POST', path: '/v1/messages', body: { title: 'Hi' } }); + + expect(source.calls[0]?.headers['content-type']).toBeUndefined(); + expect(source.calls[1]?.headers['content-type']).toBe('application/json'); + expect(source.calls[1]?.body).toEqual({ title: 'Hi' }); + }); + + it('serializes a query and skips absent values', async () => { + const source = stub([envelope({})]); + + await transportFor(source).request({ + method: 'GET', + path: '/v1/subscribers', + query: { limit: 10, search: 'ada', cursor: undefined, topic: null, enabled: false }, + }); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/subscribers?limit=10&search=ada&enabled=false'); + }); + + it('omits the question mark when every value is absent', async () => { + const source = stub([envelope({})]); + + await transportFor(source).request({ method: 'GET', path: '/v1/topics', query: { cursor: undefined } }); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/topics'); + }); + + it('passes an idempotency key as a header', async () => { + const source = stub([envelope({})]); + + await transportFor(source).request({ + method: 'POST', + path: '/v1/messages', + body: {}, + idempotencyKey: 'key_1', + }); + + expect(source.calls[0]?.headers['idempotency-key']).toBe('key_1'); + }); +}); + +describe('Transport.with', () => { + it('adds a header without touching the original', async () => { + const source = stub([envelope({}), envelope({})]); + const base = transportFor(source); + const scoped = base.with({ 'buzzkit-tenant': 'acme' }); + + await scoped.request({ method: 'GET', path: '/v1/topics' }); + await base.request({ method: 'GET', path: '/v1/topics' }); + + expect(source.calls[0]?.headers['buzzkit-tenant']).toBe('acme'); + expect(source.calls[1]?.headers['buzzkit-tenant']).toBeUndefined(); + }); + + it('removes a header when given null', async () => { + const source = stub([envelope({})]); + + await transportFor(source).with({ authorization: null }).request({ method: 'GET', path: '/v1/health' }); + + expect(source.calls[0]?.headers.authorization).toBeUndefined(); + }); +}); + +describe('Transport error mapping', () => { + it('throws the mapped class with the API detail', async () => { + const source = stub([failure(404, { code: 'not_found', message: 'Message not found', param: 'id' })]); + + const caught = await transportFor(source) + .request({ method: 'GET', path: '/v1/messages/msg_x' }) + .catch((error: NotFoundError) => error); + + expect(caught).toBeInstanceOf(NotFoundError); + expect(caught.message).toBe('Message not found'); + expect(caught.code).toBe('not_found'); + expect(caught.param).toBe('id'); + expect(caught.requestId).toBe('req_stub'); + }); + + it('throws on a 200 envelope that reports failure', async () => { + const body = JSON.stringify({ + success: false, + data: null, + error: { code: 'conflict', message: 'Already exists' }, + metadata: { timestamp: 'x' }, + }); + const source = stub([ + new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }), + ]); + + await expect(transportFor(source).request({ method: 'POST', path: '/v1/topics' })).rejects.toThrow( + 'Already exists' + ); + }); + + it('reports a non-JSON error body without inventing a code', async () => { + const source = stub([new Response('502', { status: 502 })]); + + const caught = await transportFor(source) + .request({ method: 'GET', path: '/v1/health' }) + .catch((error: ServerError) => error); + + expect(caught).toBeInstanceOf(ServerError); + expect(caught.code).toBe('internal'); + expect(caught.message).toContain('502'); + }); + + it('reports a success body that is not JSON as a parse failure', async () => { + const source = stub([new Response('not json', { status: 200 })]); + + const caught = await transportFor(source) + .request({ method: 'GET', path: '/v1/health' }) + .catch((error: BuzzKitError) => error); + + expect(caught.code).toBe('parse'); + expect(caught.status).toBe(200); + }); + + it('wraps a network failure as a connection error', async () => { + const source = stub([ + () => { + throw new TypeError('fetch failed'); + }, + ]); + + const caught = await transportFor(source) + .request({ method: 'GET', path: '/v1/health' }) + .catch((error: ConnectionError) => error); + + expect(caught).toBeInstanceOf(ConnectionError); + expect(caught.message).toContain('fetch failed'); + }); + + it('wraps an abort as a timeout', async () => { + const aborted = new Error('The operation was aborted'); + aborted.name = 'TimeoutError'; + const source = stub([ + () => { + throw aborted; + }, + ]); + + await expect(transportFor(source).request({ method: 'GET', path: '/v1/health' })).rejects.toBeInstanceOf( + TimeoutError + ); + }); + + it('times out a request that never settles', async () => { + const source = stub([ + () => + new Promise(() => { + return; + }), + ]); + + await expect( + transportFor(source, { timeoutMs: 20 }).request({ method: 'GET', path: '/v1/health' }) + ).rejects.toBeInstanceOf(TimeoutError); + }); +}); + +describe('Transport retries', () => { + it('retries an idempotent method and returns the eventual success', async () => { + const source = stub([ + failure(503, { code: 'unavailable' }, { 'retry-after': '0' }), + envelope({ ok: true }), + ]); + + await expect( + transportFor(source, { maxRetries: 2 }).request({ method: 'GET', path: '/v1/health' }) + ).resolves.toEqual({ ok: true }); + expect(source.calls).toHaveLength(2); + }); + + it('stops after maxRetries and throws the last error', async () => { + const responses = Array.from({ length: 3 }, () => + failure(429, { code: 'rate_limited' }, { 'retry-after': '0' }) + ); + const source = stub(responses); + + await expect( + transportFor(source, { maxRetries: 2 }).request({ method: 'GET', path: '/v1/topics' }) + ).rejects.toBeInstanceOf(RateLimitError); + expect(source.calls).toHaveLength(3); + }); + + it('never retries a POST without an idempotency key', async () => { + const source = stub([failure(503, { code: 'unavailable' }, { 'retry-after': '0' })]); + + await expect( + transportFor(source, { maxRetries: 3 }).request({ method: 'POST', path: '/v1/subscriptions', body: {} }) + ).rejects.toBeInstanceOf(ServerError); + expect(source.calls).toHaveLength(1); + }); + + it('retries a POST that carries an idempotency key', async () => { + const source = stub([ + failure(503, { code: 'unavailable' }, { 'retry-after': '0' }), + envelope({ id: 'm' }), + ]); + + await transportFor(source, { maxRetries: 1 }).request({ + method: 'POST', + path: '/v1/messages', + body: {}, + idempotencyKey: 'key_1', + }); + + expect(source.calls).toHaveLength(2); + expect(source.calls[1]?.headers['idempotency-key']).toBe('key_1'); + }); + + it('never retries a permanent failure', async () => { + const source = stub([failure(404, { code: 'not_found' })]); + + await expect( + transportFor(source, { maxRetries: 3 }).request({ method: 'GET', path: '/v1/messages/msg_x' }) + ).rejects.toBeInstanceOf(NotFoundError); + expect(source.calls).toHaveLength(1); + }); + + it('retries a network failure', async () => { + const source = stub([ + () => { + throw new TypeError('fetch failed'); + }, + envelope({ ok: true }), + ]); + + await expect( + transportFor(source, { maxRetries: 1 }).request({ method: 'GET', path: '/v1/health' }) + ).resolves.toEqual({ ok: true }); + }); + + it('waits the delay the server asked for', async () => { + vi.useFakeTimers(); + + const source = stub([ + failure(429, { code: 'rate_limited' }, { 'retry-after': '5' }), + envelope({ ok: true }), + ]); + const pending = transportFor(source, { maxRetries: 1 }).request({ method: 'GET', path: '/v1/topics' }); + + await vi.advanceTimersByTimeAsync(4_000); + expect(source.calls).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1_000); + await expect(pending).resolves.toEqual({ ok: true }); + + vi.useRealTimers(); + }); +}); + +describe('Transport cancellation', () => { + it('aborts when the caller signal fires', async () => { + const controller = new AbortController(); + const source = stub([ + () => + new Promise(() => { + return; + }), + ]); + + const pending = transportFor(source).request({ + method: 'GET', + path: '/v1/health', + signal: controller.signal, + }); + controller.abort(); + + await expect(pending).rejects.toBeInstanceOf(ConnectionError); + }); + + it('classifies and retries a body that dies after the headers arrived', async () => { + let calls = 0; + const transport = new Transport({ + baseUrl: 'https://api.test', + headers: {}, + maxRetries: 2, + timeoutMs: 1000, + fetch: async () => { + calls += 1; + return new Response( + new ReadableStream({ + start(controller) { + controller.error(new DOMException('The operation was aborted', 'TimeoutError')); + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }, + }); + + await expect(transport.request({ method: 'GET', path: '/v1/health' })).rejects.toBeInstanceOf( + TimeoutError + ); + expect(calls).toBe(3); + }); + + it('reports a body that fails for any other reason as a connection failure', async () => { + const transport = new Transport({ + baseUrl: 'https://api.test', + headers: {}, + maxRetries: 0, + timeoutMs: 1000, + fetch: async () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error('socket hang up')); + }, + }), + { status: 200 } + ), + }); + + await expect(transport.request({ method: 'GET', path: '/v1/health' })).rejects.toThrowError( + /response could not be read/ + ); + }); + + it('hands back the server delay instead of blocking the caller for minutes', async () => { + let calls = 0; + const source = stub([ + () => { + calls += 1; + return failure(503, { code: 'unavailable', message: 'maintenance' }, { 'retry-after': '600' }); + }, + ]); + const transport = new Transport({ + baseUrl: 'https://api.test', + headers: {}, + maxRetries: 3, + timeoutMs: 1000, + fetch: source.fetch, + }); + + const caught = await transport + .request({ method: 'GET', path: '/v1/health' }) + .then(() => null) + .catch((error: unknown) => error); + + expect(calls).toBe(1); + expect((caught as BuzzKitError).retryAfterSeconds).toBe(600); + expect((caught as BuzzKitError).status).toBe(503); + }); +}); diff --git a/packages/buzzkit/test/expressions/lint.test.ts b/packages/buzzkit/test/expressions/lint.test.ts index 003171c0..3a9ca5ea 100644 --- a/packages/buzzkit/test/expressions/lint.test.ts +++ b/packages/buzzkit/test/expressions/lint.test.ts @@ -89,7 +89,7 @@ describe('lintExpression', () => { 'any[0]: This object is neither a group nor a condition. Start it with one of "all", "any", "not" or "ref", "count", "never", "lastSeen", "channel".', ]); expect(messages({ all: [{ channel: 'sms ' }] })).toEqual([ - 'all[0].channel: "channel" must be one of "push", "email", "sms", got "sms ".', + 'all[0].channel: "channel" must be one of "push", "email", got "sms ".', ]); expect(messages({ count: 'order.completed', gte: '2' })).toEqual([ 'gte: "gte" takes a whole number of times, 0 or more, got "2".', diff --git a/packages/buzzkit/test/expressions/parse.test.ts b/packages/buzzkit/test/expressions/parse.test.ts index 67c9d008..8b76cb98 100644 --- a/packages/buzzkit/test/expressions/parse.test.ts +++ b/packages/buzzkit/test/expressions/parse.test.ts @@ -19,7 +19,7 @@ describe('expressionProblem', () => { it('names what is wrong and where', () => { expect(expressionProblem({ all: [{ channel: 'fax' }] })).toBe( - '"channel" must be one of "push", "email", "sms", got "fax". (all[0].channel)' + '"channel" must be one of "push", "email", got "fax". (all[0].channel)' ); expect(expressionProblem({ all: [] })).toBe('"all" needs at least one condition. (all)'); expect(expressionProblem({ all: [{ lastSeen: {} }] })).toBe( diff --git a/packages/buzzkit/test/react/hooks.test.tsx b/packages/buzzkit/test/react/hooks.test.tsx new file mode 100644 index 00000000..00177089 --- /dev/null +++ b/packages/buzzkit/test/react/hooks.test.tsx @@ -0,0 +1,268 @@ +import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ConfigurationError } from '../../src/core/errors'; +import { BuzzKitProvider, useBuzzKit, useIdentity } from '../../src/react/context'; +import { useIdentify, usePreferences, useTrack } from '../../src/react/hooks'; +import { envelope, failure, page, type Stub, stub } from '../utils/stub'; + +const identity = { externalId: 'user_123', identityHash: 'deadbeef' }; + +const preference = { + id: 'tpc_1', + slug: 'product-updates', + name: 'Product updates', + description: null, + category: null, + channels: { push: { optedIn: true, isDefault: false } }, +}; + +afterEach(cleanup); + +function wrapperFor(source: Stub) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +describe('useBuzzKit', () => { + it('refuses to run outside a provider', () => { + expect(() => renderHook(() => useBuzzKit())).toThrow(ConfigurationError); + }); + + it('hands out the client and its identity inside a provider', () => { + const source = stub([]); + const { result } = renderHook(() => ({ client: useBuzzKit(), identity: useIdentity() }), { + wrapper: wrapperFor(source), + }); + + expect(result.current.client.identity).toEqual(identity); + expect(result.current.identity).toEqual(identity); + }); + + it('keeps the same client across re-renders', () => { + const source = stub([]); + const { result, rerender } = renderHook(() => useBuzzKit(), { wrapper: wrapperFor(source) }); + + const first = result.current; + rerender(); + + expect(result.current).toBe(first); + }); +}); + +describe('usePreferences', () => { + it('loads on mount', async () => { + const source = stub([page([preference])]); + const { result } = renderHook(() => usePreferences(), { wrapper: wrapperFor(source) }); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toEqual([preference]); + expect(result.current.error).toBeNull(); + expect(source.calls).toHaveLength(1); + }); + + it('surfaces a load failure without throwing', async () => { + const source = stub([failure(401, { code: 'invalid_identity_hash' })]); + const { result } = renderHook(() => usePreferences(), { wrapper: wrapperFor(source) }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.data).toBeNull(); + }); + + it('replaces the list from the update response', async () => { + const muted = { ...preference, channels: { push: { optedIn: false, isDefault: false } } }; + const source = stub([page([preference]), page([muted])]); + const { result } = renderHook(() => usePreferences(), { wrapper: wrapperFor(source) }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { + await result.current.update({ 'product-updates': { push: false } }); + }); + + expect(result.current.data).toEqual([muted]); + expect(source.calls[1]?.method).toBe('PATCH'); + }); + + it('refetches on refresh', async () => { + const source = stub([page([preference]), page([preference])]); + const { result } = renderHook(() => usePreferences(), { wrapper: wrapperFor(source) }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { + await result.current.refresh(); + }); + + expect(source.calls).toHaveLength(2); + }); +}); + +describe('useIdentify', () => { + it('does not call the API until asked', () => { + const source = stub([]); + const { result } = renderHook(() => useIdentify({ email: 'ada@acme.com' }), { + wrapper: wrapperFor(source), + }); + + expect(source.calls).toHaveLength(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeNull(); + }); + + it('identifies with the hook params', async () => { + const source = stub([envelope({ id: 'sbr_1', externalId: 'user_123' })]); + const { result } = renderHook(() => useIdentify({ email: 'ada@acme.com' }), { + wrapper: wrapperFor(source), + }); + + await act(async () => { + await result.current.identify(); + }); + + expect(source.calls[0]?.body).toMatchObject({ email: 'ada@acme.com', externalId: 'user_123' }); + expect(result.current.data).toEqual({ id: 'sbr_1', externalId: 'user_123' }); + }); + + it('lets a call override the hook params', async () => { + const source = stub([envelope({ id: 'sbr_1' })]); + const { result } = renderHook(() => useIdentify({ email: 'ada@acme.com' }), { + wrapper: wrapperFor(source), + }); + + await act(async () => { + await result.current.identify({ attributes: { plan: 'pro' } }); + }); + + expect(source.calls[0]?.body).toMatchObject({ attributes: { plan: 'pro' } }); + expect(source.calls[0]?.body).not.toMatchObject({ email: 'ada@acme.com' }); + }); + + it('records the error and still rejects so the caller can react', async () => { + const source = stub([failure(401, { code: 'identity_required' })]); + const { result } = renderHook(() => useIdentify(), { wrapper: wrapperFor(source) }); + + await act(async () => { + await expect(result.current.identify()).rejects.toBeInstanceOf(Error); + }); + + expect(result.current.error).toBeInstanceOf(Error); + }); +}); + +describe('useTrack', () => { + it('returns a stable function that posts one event', async () => { + const source = stub([page([{ id: 'evt_1' }])]); + const { result, rerender } = renderHook(() => useTrack(), { wrapper: wrapperFor(source) }); + + const first = result.current; + rerender(); + expect(result.current).toBe(first); + + await act(async () => { + await result.current('pricing.viewed', { plan: 'pro' }); + }); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/client/events'); + expect(source.calls[0]?.body).toMatchObject({ + source: 'web', + events: [{ name: 'pricing.viewed', data: { plan: 'pro' } }], + }); + }); +}); + +describe('BuzzKitProvider', () => { + it('works with only a publishable key', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useIdentity(), { wrapper }); + + expect(result.current).toBeNull(); + }); + + it('rebuilds the client when the identity changes', () => { + const CurrentSubscriber = () => {useIdentity()?.externalId ?? 'none'}; + const tree = (externalId: string) => ( + + + + ); + + const { container, rerender } = render(tree('user_1')); + expect(container.textContent).toBe('user_1'); + + rerender(tree('user_2')); + expect(container.textContent).toBe('user_2'); + }); + + it('keeps one client when the identity object is rebuilt with the same values', () => { + const source = stub([]); + const seen = new Set(); + function Probe() { + seen.add(useBuzzKit()); + return null; + } + const tree = () => ( + + + + ); + + const { rerender } = render(tree()); + rerender(tree()); + + expect(seen.size).toBe(1); + }); + + it('never lets an older refresh overwrite a newer one', async () => { + const slow = { ...preference, slug: 'slow' }; + const fast = { ...preference, slug: 'fast' }; + let releaseSlow = () => {}; + const source = stub([ + () => + new Promise((resolve) => { + releaseSlow = () => resolve(page([slow])); + }), + page([fast]), + ]); + + const { result } = renderHook(() => usePreferences(), { wrapper: wrapperFor(source) }); + + await act(async () => { + await result.current.refresh(); + releaseSlow(); + }); + await waitFor(() => expect(result.current.data?.[0]?.slug).toBe('fast')); + + expect(result.current.data?.[0]?.slug).toBe('fast'); + }); + + it('settles with an error when an update fails after superseding a refresh', async () => { + const source = stub([page([preference]), failure(500, { code: 'internal', message: 'boom' })]); + const { result } = renderHook(() => usePreferences(), { wrapper: wrapperFor(source) }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.update({ 'product-updates': { push: false } }).catch(() => undefined); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeInstanceOf(Error); + }); +}); diff --git a/packages/buzzkit/test/resources/common.test.ts b/packages/buzzkit/test/resources/common.test.ts new file mode 100644 index 00000000..71b3ae19 --- /dev/null +++ b/packages/buzzkit/test/resources/common.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + ACTOR_TYPES, + ALIAS_SOURCES, + CHANNELS, + CREDENTIAL_STATUSES, + DELIVERY_ATTEMPT_OUTCOMES, + DELIVERY_STATUSES, + ENVIRONMENTS, + EVENT_FILTER_SOURCES, + EVENT_SOURCES, + EVENT_VOLUME_RANGES, + KEY_KINDS, + LIVE_ACTIVITY_EVENTS, + MEMBER_ROLES, + MESSAGE_STATUSES, + PLATFORMS, + PROVIDERS, + RUN_STATUSES, + SOURCE_DELIVERY_OUTCOMES, + STATS_INTERVALS, + SUBSCRIPTION_STATUSES, + WEBHOOK_DELIVERY_STATUSES, + WEBHOOK_EVENT_SOURCES, + WORKFLOW_STATUSES, +} from '../../src/resources/common'; + +const VOCABULARIES = { + ACTOR_TYPES, + ALIAS_SOURCES, + CHANNELS, + CREDENTIAL_STATUSES, + DELIVERY_ATTEMPT_OUTCOMES, + DELIVERY_STATUSES, + ENVIRONMENTS, + EVENT_FILTER_SOURCES, + EVENT_SOURCES, + EVENT_VOLUME_RANGES, + KEY_KINDS, + LIVE_ACTIVITY_EVENTS, + MEMBER_ROLES, + MESSAGE_STATUSES, + PLATFORMS, + PROVIDERS, + RUN_STATUSES, + SOURCE_DELIVERY_OUTCOMES, + STATS_INTERVALS, + SUBSCRIPTION_STATUSES, + WEBHOOK_DELIVERY_STATUSES, + WEBHOOK_EVENT_SOURCES, + WORKFLOW_STATUSES, +}; + +const POSTGRES_BACKED = { + ACTOR_TYPES: ['member', 'user', 'key', 'system'], + ALIAS_SOURCES: ['system', 'manual'], + CHANNELS: ['push', 'email'], + CREDENTIAL_STATUSES: ['unvalidated', 'active', 'invalid'], + DELIVERY_ATTEMPT_OUTCOMES: ['sent', 'retry', 'failed', 'invalid'], + DELIVERY_STATUSES: ['pending', 'retrying', 'sent', 'delivered', 'bounced', 'failed', 'invalid'], + ENVIRONMENTS: ['production', 'sandbox'], + KEY_KINDS: ['workspace', 'tenant', 'client'], + MEMBER_ROLES: ['member', 'admin', 'owner'], + MESSAGE_STATUSES: ['queued', 'processing', 'completed', 'scheduled', 'canceled'], + PLATFORMS: ['ios', 'android'], + PROVIDERS: ['apns', 'fcm', 'resend'], + SOURCE_DELIVERY_OUTCOMES: ['event', 'duplicate', 'dropped', 'rejected', 'unverified'], + SUBSCRIPTION_STATUSES: ['active', 'invalid'], + WEBHOOK_DELIVERY_STATUSES: ['pending', 'success', 'failed', 'exhausted'], + WEBHOOK_EVENT_SOURCES: ['audit', 'stream'], + WORKFLOW_STATUSES: ['draft', 'active', 'paused'], +}; + +describe('the shared vocabularies', () => { + it('are non-empty lists of unique lowercase names', () => { + for (const [name, values] of Object.entries(VOCABULARIES)) { + expect(values.length, name).toBeGreaterThan(0); + expect(new Set(values).size, name).toBe(values.length); + for (const value of values) expect(value, `${name}.${value}`).toBe(value.toLowerCase()); + } + }); + + it('pins the values Postgres enums are generated from, so a change needs a migration', () => { + for (const [name, expected] of Object.entries(POSTGRES_BACKED)) { + expect([...VOCABULARIES[name as keyof typeof POSTGRES_BACKED]], name).toEqual(expected); + } + }); + + it('keeps the run statuses the engine reports', () => { + expect([...RUN_STATUSES].sort()).toEqual( + ['canceled', 'completed', 'failed', 'running', 'sleeping', 'waiting'].sort() + ); + }); + + it('separates the sources an event can be written with from the ones it can be filtered by', () => { + expect([...EVENT_SOURCES].sort()).toEqual(['android', 'ios', 'server', 'system', 'web']); + expect([...EVENT_FILTER_SOURCES].sort()).toEqual([ + 'android', + 'ios', + 'server', + 'system', + 'web', + 'webhook', + ]); + }); +}); diff --git a/packages/buzzkit/test/resources/resources.test.ts b/packages/buzzkit/test/resources/resources.test.ts new file mode 100644 index 00000000..ba65a6a4 --- /dev/null +++ b/packages/buzzkit/test/resources/resources.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from 'vitest'; +import { BuzzKit } from '../../src/server/buzzkit'; +import { envelope, page, type Stub, stub } from '../utils/stub'; + +type Call = { method: string; url: string; body?: unknown }; + +function client(source: Stub) { + return new BuzzKit({ apiKey: 'bk_ws_k', baseUrl: 'https://api.test', fetch: source.fetch }); +} + +async function record(invoke: (buzzkit: BuzzKit) => Promise, responses = 1): Promise { + const source = stub(Array.from({ length: responses }, () => envelope({}))); + await invoke(client(source)); + + const [call] = source.calls; + return { method: call?.method ?? '', url: call?.url ?? '', body: call?.body }; +} + +async function recordList(invoke: (buzzkit: BuzzKit) => Promise): Promise { + const source = stub([page([])]); + await invoke(client(source)); + + const [call] = source.calls; + return { method: call?.method ?? '', url: call?.url ?? '', body: call?.body }; +} + +const base = 'https://api.test'; + +describe('messages', () => { + it('maps every method onto its route', async () => { + await expect(recordList((b) => b.messages.list({ status: 'queued' }))).resolves.toMatchObject({ + method: 'GET', + url: `${base}/v1/messages?status=queued`, + }); + await expect(record((b) => b.messages.retrieve('msg_1'))).resolves.toMatchObject({ + method: 'GET', + url: `${base}/v1/messages/msg_1`, + }); + await expect(record((b) => b.messages.cancel('msg_1'))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/messages/msg_1/cancel`, + }); + await expect( + recordList((b) => b.messages.deliveries('msg_1', { status: 'sent' })) + ).resolves.toMatchObject({ method: 'GET', url: `${base}/v1/messages/msg_1/deliveries?status=sent` }); + }); +}); + +describe('subscribers and subscriptions', () => { + it('maps every method onto its route', async () => { + await expect(recordList((b) => b.subscribers.list({ search: 'ada' }))).resolves.toMatchObject({ + url: `${base}/v1/subscribers?search=ada`, + }); + await expect(record((b) => b.subscribers.upsert('u1', { email: 'a@b.c' }))).resolves.toMatchObject({ + method: 'PUT', + url: `${base}/v1/subscribers/u1`, + body: { email: 'a@b.c' }, + }); + await expect(record((b) => b.subscribers.remove('u1'))).resolves.toMatchObject({ method: 'DELETE' }); + await expect( + record((b) => b.subscriptions.create({ externalId: 'u1', token: 't' })) + ).resolves.toMatchObject({ method: 'POST', url: `${base}/v1/subscriptions` }); + await expect(record((b) => b.subscriptions.update('sbn_1', { enabled: false }))).resolves.toMatchObject({ + method: 'PATCH', + url: `${base}/v1/subscriptions/sbn_1`, + body: { enabled: false }, + }); + await expect(record((b) => b.subscriptions.remove('sbn_1'))).resolves.toMatchObject({ method: 'DELETE' }); + }); + + it('lists and adds the ids a subscriber has been known by', async () => { + await expect(recordList((b) => b.subscribers.aliases('u1'))).resolves.toMatchObject({ + method: 'GET', + url: `${base}/v1/subscribers/u1/aliases`, + }); + await expect(recordList((b) => b.subscribers.addAlias('u1', 'legacy_7'))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/subscribers/u1/aliases`, + body: { externalId: 'legacy_7' }, + }); + }); + + it('returns every alias the API answers with', async () => { + const alias = { externalId: 'legacy_7', source: 'manual', createdAt: '2026-09-06T00:00:00.000Z' }; + const source = stub([page([alias])]); + + const aliases = await client(source).subscribers.aliases('u1'); + + expect(aliases.items).toEqual([alias]); + }); +}); + +describe('topics', () => { + it('maps every method onto its route', async () => { + await expect(recordList((b) => b.topics.list())).resolves.toMatchObject({ url: `${base}/v1/topics` }); + await expect(record((b) => b.topics.create({ slug: 't', name: 'T' }))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/topics`, + }); + await expect(record((b) => b.topics.retrieve('t'))).resolves.toMatchObject({ + url: `${base}/v1/topics/t`, + }); + await expect(record((b) => b.topics.update('t', { name: 'U' }))).resolves.toMatchObject({ + method: 'PATCH', + }); + await expect(record((b) => b.topics.remove('t'))).resolves.toMatchObject({ method: 'DELETE' }); + await expect(recordList((b) => b.topicCategories.list())).resolves.toMatchObject({ + url: `${base}/v1/topic-categories`, + }); + await expect(record((b) => b.topicCategories.update('cat_1', { name: 'N' }))).resolves.toMatchObject({ + method: 'PATCH', + url: `${base}/v1/topic-categories/cat_1`, + }); + await expect(record((b) => b.topicCategories.remove('cat_1'))).resolves.toMatchObject({ + method: 'DELETE', + }); + }); +}); + +describe('segments', () => { + it('maps every method onto its route', async () => { + const expression = { ref: 'attributes.plan', eq: 'pro' } as const; + + await expect(recordList((b) => b.segments.list())).resolves.toMatchObject({ url: `${base}/v1/segments` }); + await expect( + record((b) => b.segments.create({ slug: 's', name: 'S', expression })) + ).resolves.toMatchObject({ method: 'POST', url: `${base}/v1/segments` }); + await expect(record((b) => b.segments.preview(expression))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/segments/preview`, + body: { expression }, + }); + await expect(record((b) => b.segments.retrieve('s'))).resolves.toMatchObject({ + url: `${base}/v1/segments/s`, + }); + await expect(record((b) => b.segments.update('s', { name: 'S2' }))).resolves.toMatchObject({ + method: 'PATCH', + }); + await expect(record((b) => b.segments.remove('s'))).resolves.toMatchObject({ method: 'DELETE' }); + await expect(recordList((b) => b.segments.members('s'))).resolves.toMatchObject({ + url: `${base}/v1/segments/s/members`, + }); + }); +}); + +describe('workflows and runs', () => { + it('maps every method onto its route', async () => { + await expect(recordList((b) => b.workflows.list())).resolves.toMatchObject({ + url: `${base}/v1/workflows`, + }); + await expect( + record((b) => + b.workflows.create({ slug: 'w', name: 'W', spec: { trigger: { event: 'signup' }, steps: [] } }) + ) + ).resolves.toMatchObject({ method: 'POST' }); + await expect(record((b) => b.workflows.retrieve('w'))).resolves.toMatchObject({ + url: `${base}/v1/workflows/w`, + }); + await expect(record((b) => b.workflows.update('w', { name: 'W2' }))).resolves.toMatchObject({ + method: 'PATCH', + }); + await expect(record((b) => b.workflows.remove('w'))).resolves.toMatchObject({ method: 'DELETE' }); + await expect(record((b) => b.workflows.publish('w'))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/workflows/w/publish`, + }); + await expect(record((b) => b.workflows.pause('w'))).resolves.toMatchObject({ + url: `${base}/v1/workflows/w/pause`, + }); + await expect(record((b) => b.workflows.schedule('w'))).resolves.toMatchObject({ + url: `${base}/v1/workflows/w/schedule`, + }); + await expect(record((b) => b.workflows.test('w', { externalId: 'u1' }))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/workflows/w/test`, + body: { externalId: 'u1' }, + }); + await expect(recordList((b) => b.workflows.runs('w', { status: 'running' }))).resolves.toMatchObject({ + url: `${base}/v1/workflows/w/runs?status=running`, + }); + await expect(recordList((b) => b.runs.list({ workflow: 'w' }))).resolves.toMatchObject({ + url: `${base}/v1/runs?workflow=w`, + }); + await expect(record((b) => b.runs.retrieve('run_1'))).resolves.toMatchObject({ + url: `${base}/v1/runs/run_1`, + }); + }); +}); + +describe('events, stats and deliveries', () => { + it('maps every method onto its route', async () => { + await expect(recordList((b) => b.events.list({ name: 'a.b' }))).resolves.toMatchObject({ + url: `${base}/v1/events?name=a.b`, + }); + await expect(recordList((b) => b.events.names())).resolves.toMatchObject({ + url: `${base}/v1/events/names`, + }); + await expect(record((b) => b.events.name('a.b', { range: '7d' }))).resolves.toMatchObject({ + url: `${base}/v1/events/names/a.b?range=7d`, + }); + await expect(record((b) => b.events.volume({ range: '24h' }))).resolves.toMatchObject({ + url: `${base}/v1/events/volume?range=24h`, + }); + await expect(record((b) => b.stats.retrieve({ interval: 'day' }))).resolves.toMatchObject({ + url: `${base}/v1/stats?interval=day`, + }); + await expect(record((b) => b.deliveries.retrieve('dlv_1'))).resolves.toMatchObject({ + url: `${base}/v1/deliveries/dlv_1`, + }); + await expect(recordList((b) => b.deliveries.attempts('dlv_1'))).resolves.toMatchObject({ + url: `${base}/v1/deliveries/dlv_1/attempts`, + }); + }); +}); + +describe('credentials, secrets and sources', () => { + it('maps every method onto its route', async () => { + await expect(recordList((b) => b.credentials.list())).resolves.toMatchObject({ + url: `${base}/v1/credentials`, + }); + await expect( + record((b) => b.credentials.create({ provider: 'resend', apiKey: 're_1' })) + ).resolves.toMatchObject({ method: 'POST', body: { provider: 'resend', apiKey: 're_1' } }); + await expect(record((b) => b.credentials.validate('crd_1'))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/credentials/crd_1/validate`, + }); + await expect(record((b) => b.credentials.remove('crd_1'))).resolves.toMatchObject({ method: 'DELETE' }); + const apns = [ + { id: 'crd_1', environment: 'sandbox' }, + { id: 'crd_2', environment: 'production' }, + ]; + const created = await client(stub([page(apns)])).credentials.create({ + provider: 'apns', + p8: 'k', + teamId: 't', + keyId: 'k', + bundleId: 'b', + }); + expect(created).toEqual(apns); + await expect(record((b) => b.secrets.upsert('stripe', 'sk_1'))).resolves.toMatchObject({ + method: 'PUT', + url: `${base}/v1/secrets/stripe`, + body: { value: 'sk_1' }, + }); + await expect(record((b) => b.secrets.retrieve('stripe'))).resolves.toMatchObject({ + url: `${base}/v1/secrets/stripe`, + }); + await expect(record((b) => b.secrets.remove('stripe'))).resolves.toMatchObject({ method: 'DELETE' }); + await expect(record((b) => b.sources.create({ name: 'S', provider: 'stripe' }))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/sources`, + }); + await expect(record((b) => b.sources.preview('src_1', { payload: {} }))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/sources/src_1/preview`, + }); + await expect( + recordList((b) => b.sources.deliveries('src_1', { outcome: 'event' })) + ).resolves.toMatchObject({ url: `${base}/v1/sources/src_1/deliveries?outcome=event` }); + }); +}); + +describe('imports, live activities and tenants', () => { + it('maps every method onto its route', async () => { + await expect(record((b) => b.imports.create([{ externalId: 'u1' }]))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/imports`, + body: { rows: [{ externalId: 'u1' }] }, + }); + await expect( + record((b) => b.liveActivities.send({ to: 'u1', event: 'update', contentState: { score: 1 } })) + ).resolves.toMatchObject({ method: 'POST', url: `${base}/v1/live-activities/send` }); + await expect(recordList((b) => b.tenants.list())).resolves.toMatchObject({ url: `${base}/v1/tenants` }); + await expect(record((b) => b.tenants.create({ name: 'T', slug: 't' }))).resolves.toMatchObject({ + method: 'POST', + }); + await expect(record((b) => b.tenants.retrieve('t'))).resolves.toMatchObject({ + url: `${base}/v1/tenants/t`, + }); + await expect(record((b) => b.tenants.update('t', { name: 'T2' }))).resolves.toMatchObject({ + method: 'PATCH', + }); + await expect(record((b) => b.tenants.remove('t'))).resolves.toMatchObject({ method: 'DELETE' }); + }); +}); + +describe('workspaces and webhooks', () => { + it('maps every method onto its route', async () => { + await expect(recordList((b) => b.workspaces.list())).resolves.toMatchObject({ + url: `${base}/v1/workspaces`, + }); + await expect(record((b) => b.workspaces.create({ name: 'W', slug: 'w' }))).resolves.toMatchObject({ + method: 'POST', + }); + await expect(record((b) => b.workspaces.retrieve('w'))).resolves.toMatchObject({ + url: `${base}/v1/workspaces/w`, + }); + await expect(record((b) => b.workspace('w').update({ name: 'W2' }))).resolves.toMatchObject({ + method: 'PATCH', + url: `${base}/v1/workspaces/w`, + }); + await expect( + record((b) => b.workspace('w').webhooks.create({ url: 'https://x.test' })) + ).resolves.toMatchObject({ method: 'POST', url: `${base}/v1/workspaces/w/webhooks` }); + await expect(record((b) => b.workspace('w').webhooks.catalog())).resolves.toMatchObject({ + url: `${base}/v1/workspaces/w/webhooks/catalog`, + }); + await expect(record((b) => b.workspace('w').webhooks.rotate('wh_1'))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/workspaces/w/webhooks/wh_1/rotate`, + }); + await expect(record((b) => b.workspace('w').webhooks.event('whe_1'))).resolves.toMatchObject({ + url: `${base}/v1/workspaces/w/webhooks/events/whe_1`, + }); + await expect(record((b) => b.workspace('w').webhooks.delivery('wh_1', 'whd_1'))).resolves.toMatchObject({ + url: `${base}/v1/workspaces/w/webhooks/wh_1/deliveries/whd_1`, + }); + await expect(record((b) => b.workspace('w').webhooks.replay('wh_1', 'whd_1'))).resolves.toMatchObject({ + method: 'POST', + url: `${base}/v1/workspaces/w/webhooks/wh_1/deliveries/whd_1/replay`, + }); + await expect(record((b) => b.workspace('w').members.retrieve('mem_1'))).resolves.toMatchObject({ + url: `${base}/v1/workspaces/w/members/mem_1`, + }); + await expect( + recordList((b) => b.workspace('w').audit.list({ event: 'tenant.created' })) + ).resolves.toMatchObject({ url: `${base}/v1/workspaces/w/audit?event=tenant.created` }); + }); +}); + +describe('the remaining reads and writes', () => { + it('maps every method onto its route', async () => { + await expect(record((b) => b.credentials.retrieve('crd_1'))).resolves.toMatchObject({ + url: `${base}/v1/credentials/crd_1`, + }); + await expect(recordList((b) => b.secrets.list())).resolves.toMatchObject({ url: `${base}/v1/secrets` }); + await expect(recordList((b) => b.sources.list())).resolves.toMatchObject({ url: `${base}/v1/sources` }); + await expect(record((b) => b.sources.retrieve('src_1'))).resolves.toMatchObject({ + url: `${base}/v1/sources/src_1`, + }); + await expect(record((b) => b.sources.update('src_1', { status: 'paused' }))).resolves.toMatchObject({ + method: 'PATCH', + body: { status: 'paused' }, + }); + await expect(record((b) => b.sources.remove('src_1'))).resolves.toMatchObject({ method: 'DELETE' }); + await expect(record((b) => b.subscriptions.retrieve('sbn_1'))).resolves.toMatchObject({ + url: `${base}/v1/subscriptions/sbn_1`, + }); + await expect(record((b) => b.workspace('w').retrieve())).resolves.toMatchObject({ + url: `${base}/v1/workspaces/w`, + }); + await expect(record((b) => b.workspace('w').webhooks.retrieve('wh_1'))).resolves.toMatchObject({ + url: `${base}/v1/workspaces/w/webhooks/wh_1`, + }); + await expect( + record((b) => b.workspace('w').webhooks.update('wh_1', { enabled: false })) + ).resolves.toMatchObject({ method: 'PATCH', body: { enabled: false } }); + await expect(record((b) => b.workspace('w').webhooks.remove('wh_1'))).resolves.toMatchObject({ + method: 'DELETE', + }); + await expect( + recordList((b) => b.workspace('w').webhooks.deliveries('wh_1', { status: 'failed' })) + ).resolves.toMatchObject({ url: `${base}/v1/workspaces/w/webhooks/wh_1/deliveries?status=failed` }); + await expect(recordList((b) => b.workspace('w').webhooks.list())).resolves.toMatchObject({ + url: `${base}/v1/workspaces/w/webhooks`, + }); + }); +}); diff --git a/packages/buzzkit/test/server/buzzkit.test.ts b/packages/buzzkit/test/server/buzzkit.test.ts new file mode 100644 index 00000000..4b16ce83 --- /dev/null +++ b/packages/buzzkit/test/server/buzzkit.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigurationError } from '../../src/core/errors'; +import { BuzzKit } from '../../src/server/buzzkit'; +import { TenantScope, WorkspaceScope } from '../../src/server/scopes'; +import { envelope, page, type Stub, stub } from '../utils/stub'; + +function client(source: Stub, overrides: { workspace?: string; tenant?: string } = {}) { + return new BuzzKit({ apiKey: 'bk_ws_k', baseUrl: 'https://api.test', fetch: source.fetch, ...overrides }); +} + +const TENANT_RESOURCES = [ + 'messages', + 'subscribers', + 'subscriptions', + 'topics', + 'topicCategories', + 'segments', + 'workflows', + 'runs', + 'events', + 'deliveries', + 'credentials', + 'secrets', + 'sources', + 'imports', + 'liveActivities', + 'stats', +] as const; + +describe('BuzzKit', () => { + it('exposes every tenant resource, and the two workspace collections', () => { + const buzzkit = client(stub([])); + + for (const resource of TENANT_RESOURCES) { + expect(buzzkit[resource], resource).toBeTypeOf('object'); + } + expect(buzzkit.tenants).toBeTypeOf('object'); + expect(buzzkit.workspaces).toBeTypeOf('object'); + }); + + it('reads health without a tenant', async () => { + const source = stub([envelope({ status: 'ok', database: { status: 'ok', latencyMs: 3 } })]); + + await expect(client(source).health()).resolves.toEqual({ + status: 'ok', + database: { status: 'ok', latencyMs: 3 }, + }); + expect(source.calls[0]?.url).toBe('https://api.test/v1/health'); + }); +}); + +describe('BuzzKit.tenant', () => { + it('scopes a copy without touching the root client', async () => { + const source = stub([envelope({}), envelope({})]); + const buzzkit = client(source); + + const scoped = buzzkit.tenant('acme'); + await scoped.topics.list(); + await buzzkit.topics.list(); + + expect(scoped).toBeInstanceOf(TenantScope); + expect(source.calls[0]?.headers['buzzkit-tenant']).toBe('acme'); + expect(source.calls[1]?.headers['buzzkit-tenant']).toBeUndefined(); + }); + + it('carries every resource onto the scope', () => { + const scoped = client(stub([])).tenant('acme'); + + for (const resource of TENANT_RESOURCES) { + expect(scoped[resource], resource).toBeTypeOf('object'); + } + }); +}); + +describe('BuzzKit.workspace', () => { + it('puts the slug in the path', async () => { + const source = stub([page([])]); + + await client(source).workspace('acme').webhooks.list(); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/workspaces/acme/webhooks'); + }); + + it('falls back to the configured workspace', async () => { + const source = stub([page([])]); + + await client(source, { workspace: 'studio' }).workspace().members.list(); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/workspaces/studio/members'); + }); + + it('refuses when no slug is known', () => { + const buzzkit = client(stub([])); + + expect(() => buzzkit.workspace()).toThrow(ConfigurationError); + expect(() => buzzkit.workspace()).toThrow(/No workspace selected/); + }); + + it('prefers an explicit slug over the configured one', async () => { + const source = stub([page([])]); + + const scope = client(source, { workspace: 'studio' }).workspace('acme'); + await scope.audit.list(); + + expect(scope).toBeInstanceOf(WorkspaceScope); + expect(source.calls[0]?.url).toContain('/v1/workspaces/acme/audit'); + }); + + it('encodes a slug that would otherwise change the path', async () => { + const source = stub([page([])]); + + await client(source).workspace('a/b').webhooks.list(); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/workspaces/a%2Fb/webhooks'); + }); +}); + +describe('BuzzKit.send', () => { + it('posts the payload and generates an idempotency key', async () => { + const source = stub([envelope({ id: 'msg_1' })]); + + await client(source).send({ to: 'user_1', title: 'Hey', body: 'There' }); + + const [call] = source.calls; + expect(call?.method).toBe('POST'); + expect(call?.url).toBe('https://api.test/v1/messages'); + expect(call?.body).toEqual({ to: 'user_1', title: 'Hey', body: 'There' }); + expect(call?.headers['idempotency-key']).toEqual(expect.any(String)); + }); + + it('uses the caller-supplied key and keeps it out of the body', async () => { + const source = stub([envelope({ id: 'msg_1' })]); + + await client(source).send({ to: 'user_1', title: 'Hey', idempotencyKey: 'order_42' }); + + expect(source.calls[0]?.headers['idempotency-key']).toBe('order_42'); + expect(source.calls[0]?.body).toEqual({ to: 'user_1', title: 'Hey' }); + }); +}); + +describe('BuzzKit.track', () => { + it('wraps a single event into a batch', async () => { + const source = stub([page([{ id: 'evt_1' }])]); + + await client(source).track({ externalId: 'user_1', name: 'workout.completed' }); + + expect(source.calls[0]?.body).toEqual({ + events: [{ externalId: 'user_1', name: 'workout.completed' }], + }); + }); + + it('sends a batch as given', async () => { + const source = stub([page([{ id: 'evt_1' }, { id: 'evt_2' }])]); + + await client(source).track([ + { externalId: 'user_1', name: 'a' }, + { externalId: 'user_2', name: 'b' }, + ]); + + expect(source.calls[0]?.body).toEqual({ + events: [ + { externalId: 'user_1', name: 'a' }, + { externalId: 'user_2', name: 'b' }, + ], + }); + }); +}); diff --git a/packages/buzzkit/test/server/identity.test.ts b/packages/buzzkit/test/server/identity.test.ts new file mode 100644 index 00000000..ec076f14 --- /dev/null +++ b/packages/buzzkit/test/server/identity.test.ts @@ -0,0 +1,45 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { signIdentity } from '../../src/server/identity'; + +function oracle(externalId: string, secret: string): string { + return createHmac('sha256', secret).update(externalId).digest('hex'); +} + +describe('signIdentity', () => { + it('matches an independent HMAC-SHA256 implementation', async () => { + await expect(signIdentity('user_123', 'tenant-secret')).resolves.toBe( + oracle('user_123', 'tenant-secret') + ); + }); + + it('is lowercase hex of the full digest', async () => { + const hash = await signIdentity('user_123', 'tenant-secret'); + + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('is deterministic', async () => { + const [first, second] = await Promise.all([ + signIdentity('user_123', 'tenant-secret'), + signIdentity('user_123', 'tenant-secret'), + ]); + + expect(first).toBe(second); + }); + + it('changes with the subscriber and with the secret', async () => { + const base = await signIdentity('user_123', 'tenant-secret'); + + await expect(signIdentity('user_124', 'tenant-secret')).resolves.not.toBe(base); + await expect(signIdentity('user_123', 'other-secret')).resolves.not.toBe(base); + }); + + it('handles unicode and empty identifiers the same way as the oracle', async () => { + for (const externalId of ['', 'ünïcødé', '用户_1', 'a'.repeat(256)]) { + await expect(signIdentity(externalId, 'tenant-secret'), externalId).resolves.toBe( + oracle(externalId, 'tenant-secret') + ); + } + }); +}); diff --git a/packages/buzzkit/test/server/namespace.test.ts b/packages/buzzkit/test/server/namespace.test.ts new file mode 100644 index 00000000..95a78cee --- /dev/null +++ b/packages/buzzkit/test/server/namespace.test.ts @@ -0,0 +1,48 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const RESOURCES = join(import.meta.dirname, '../../src/resources'); + +const NAMESPACE = join(import.meta.dirname, '../../src/server/buzzkit.ts'); + +function listResourceTypes(): string[] { + const names: string[] = []; + + for (const file of readdirSync(RESOURCES)) { + if (!file.endsWith('.ts') || file === 'index.ts' || file === 'list.ts') continue; + + const source = readFileSync(join(RESOURCES, file), 'utf8'); + for (const match of source.matchAll(/^export type (\w+)/gm)) { + const name = match[1] as string; + if (!name.endsWith('Resource')) names.push(name); + } + } + + return names.sort(); +} + +function listNamespacedTypes(): string[] { + const source = readFileSync(NAMESPACE, 'utf8'); + return [...source.matchAll(/^ {2}export type (\w+)(?:<[^>]*>)? = R\.\w+/gm)] + .map((match) => match[1] as string) + .sort(); +} + +describe('the BuzzKit namespace', () => { + it('exposes every resource type', () => { + const missing = listResourceTypes().filter((name) => !listNamespacedTypes().includes(name)); + + expect( + missing, + `add these to the BuzzKit namespace in src/server/buzzkit.ts: ${missing.join(', ')}` + ).toEqual([]); + }); + + it('does not alias a type that no longer exists', () => { + const resources = listResourceTypes(); + const orphaned = listNamespacedTypes().filter((name) => !resources.includes(name)); + + expect(orphaned, `remove these from the BuzzKit namespace: ${orphaned.join(', ')}`).toEqual([]); + }); +}); diff --git a/packages/buzzkit/test/server/options.test.ts b/packages/buzzkit/test/server/options.test.ts new file mode 100644 index 00000000..dbf57534 --- /dev/null +++ b/packages/buzzkit/test/server/options.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { ConfigurationError } from '../../src/core/errors'; +import { resolveOptions, serverTransport } from '../../src/server/options'; +import { envelope, stub } from '../utils/stub'; + +const original = { ...process.env }; + +afterEach(() => { + process.env = { ...original }; +}); + +describe('resolveOptions', () => { + it('requires an API key', () => { + process.env.BUZZKIT_API_KEY = undefined; + delete process.env.BUZZKIT_API_KEY; + + expect(() => resolveOptions({})).toThrow(ConfigurationError); + expect(() => resolveOptions({})).toThrow(/BUZZKIT_API_KEY/); + }); + + it('falls back to the environment', () => { + process.env.BUZZKIT_API_KEY = 'bk_ws_from_env'; + + expect(resolveOptions({}).apiKey).toBe('bk_ws_from_env'); + }); + + it('prefers an explicit key over the environment', () => { + process.env.BUZZKIT_API_KEY = 'bk_ws_from_env'; + + expect(resolveOptions({ apiKey: 'bk_ws_explicit' }).apiKey).toBe('bk_ws_explicit'); + }); + + it('refuses a client key', () => { + expect(() => resolveOptions({ apiKey: 'bk_pk_public' })).toThrow(ConfigurationError); + }); + + it('applies the documented defaults', () => { + const resolved = resolveOptions({ apiKey: 'bk_ws_k' }); + + expect(resolved.baseUrl).toBe('https://api.buzzkit.dev'); + expect(resolved.timeoutMs).toBe(30_000); + expect(resolved.maxRetries).toBe(2); + expect(resolved.tenant).toBeNull(); + expect(resolved.workspace).toBeNull(); + }); + + it('reads a base URL from the environment and trims a trailing slash', () => { + process.env.BUZZKIT_API_KEY = 'bk_ws_k'; + process.env.BUZZKIT_BASE_URL = 'https://buzzkit.internal/'; + + expect(resolveOptions({}).baseUrl).toBe('https://buzzkit.internal'); + expect(resolveOptions({ baseUrl: 'https://other.test/' }).baseUrl).toBe('https://other.test'); + }); +}); + +describe('serverTransport', () => { + it('sends the bearer token and no scope headers by default', async () => { + const source = stub([envelope({})]); + const transport = serverTransport( + resolveOptions({ apiKey: 'bk_ws_k', baseUrl: 'https://api.test', fetch: source.fetch }) + ); + + await transport.request({ method: 'GET', path: '/v1/health' }); + + expect(source.calls[0]?.headers.authorization).toBe('Bearer bk_ws_k'); + expect(source.calls[0]?.headers['buzzkit-tenant']).toBeUndefined(); + expect(source.calls[0]?.headers['buzzkit-workspace']).toBeUndefined(); + }); + + it('sends the configured tenant and workspace', async () => { + const source = stub([envelope({})]); + const transport = serverTransport( + resolveOptions({ + apiKey: 'bk_ws_k', + baseUrl: 'https://api.test', + tenant: 'acme', + workspace: 'studio', + fetch: source.fetch, + }) + ); + + await transport.request({ method: 'GET', path: '/v1/health' }); + + expect(source.calls[0]?.headers['buzzkit-tenant']).toBe('acme'); + expect(source.calls[0]?.headers['buzzkit-workspace']).toBe('studio'); + }); + + it('keeps caller headers but never lets them replace the credential', async () => { + const source = stub([envelope({})]); + const transport = serverTransport( + resolveOptions({ + apiKey: 'bk_ws_k', + baseUrl: 'https://api.test', + headers: { 'x-trace': 'abc', authorization: 'Bearer spoofed' }, + fetch: source.fetch, + }) + ); + + await transport.request({ method: 'GET', path: '/v1/health' }); + + expect(source.calls[0]?.headers['x-trace']).toBe('abc'); + expect(source.calls[0]?.headers.authorization).toBe('Bearer bk_ws_k'); + }); +}); diff --git a/packages/buzzkit/test/server/subscriber.test.ts b/packages/buzzkit/test/server/subscriber.test.ts new file mode 100644 index 00000000..ea152951 --- /dev/null +++ b/packages/buzzkit/test/server/subscriber.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { BuzzKitError } from '../../src/core/errors'; +import { BuzzKit } from '../../src/server/buzzkit'; +import { envelope, page, type Stub, stub } from '../utils/stub'; + +const record = { + id: 'sbr_1', + externalId: 'user_123', + attributes: { plan: 'pro' }, + verified: false, + identityVerifiedAt: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +function client(source: Stub) { + return new BuzzKit({ apiKey: 'bk_ws_k', baseUrl: 'https://api.test', fetch: source.fetch }); +} + +describe('BuzzKit.subscriber', () => { + it('builds a handle without a request', () => { + const source = stub([]); + const subscriber = client(source).subscriber('user_123'); + + expect(source.calls).toHaveLength(0); + expect(subscriber.externalId).toBe('user_123'); + expect(subscriber.data).toBeNull(); + }); +}); + +describe('BuzzKit.identify', () => { + it('upserts and returns a handle carrying the record', async () => { + const source = stub([envelope(record)]); + + const subscriber = await client(source).identify('user_123', { email: 'ada@acme.com' }); + + expect(source.calls[0]?.method).toBe('PUT'); + expect(source.calls[0]?.url).toBe('https://api.test/v1/subscribers/user_123'); + expect(source.calls[0]?.body).toEqual({ email: 'ada@acme.com' }); + expect(subscriber.data.id).toBe('sbr_1'); + expect(subscriber.externalId).toBe('user_123'); + }); + + it('upserts with no changes when given nothing', async () => { + const source = stub([envelope(record)]); + + await client(source).identify('user_123'); + + expect(source.calls[0]?.body).toEqual({}); + }); + + it('encodes an external id that would change the path', async () => { + const source = stub([envelope(record)]); + + await client(source).identify('tenant/user 1'); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/subscribers/tenant%2Fuser%201'); + }); +}); + +describe('SubscriberScope', () => { + it('binds the external id into a send', async () => { + const source = stub([envelope({ id: 'msg_1' })]); + + await client(source).subscriber('user_123').send({ title: 'Hey', body: 'There' }); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/messages'); + expect(source.calls[0]?.body).toEqual({ title: 'Hey', body: 'There', to: 'user_123' }); + }); + + it('tracks one event and unwraps it', async () => { + const tracked = { id: 'evt_1', name: 'workout.completed', status: 'accepted' }; + const source = stub([page([tracked])]); + + const event = await client(source).subscriber('user_123').track('workout.completed', { minutes: 30 }); + + expect(source.calls[0]?.body).toEqual({ + events: [{ externalId: 'user_123', name: 'workout.completed', data: { minutes: 30 } }], + }); + expect(event).toEqual(tracked); + }); + + it('tracks an event with no data', async () => { + const source = stub([page([{ id: 'evt_1' }])]); + + await client(source).subscriber('user_123').track('app.opened'); + + expect(source.calls[0]?.body).toEqual({ events: [{ externalId: 'user_123', name: 'app.opened' }] }); + }); + + it('fails loudly when the API accepts no event', async () => { + const source = stub([page([])]); + + await expect(client(source).subscriber('user_123').track('workout.completed')).rejects.toBeInstanceOf( + BuzzKitError + ); + }); + + it('injects the external id into a subscription', async () => { + const source = stub([envelope({ id: 'sbn_1' })]); + + await client(source).subscriber('user_123').subscribe({ token: 'abc', platform: 'ios' }); + + expect(source.calls[0]?.url).toBe('https://api.test/v1/subscriptions'); + expect(source.calls[0]?.body).toEqual({ token: 'abc', platform: 'ios', externalId: 'user_123' }); + }); + + it('routes every read at the subscriber path', async () => { + const source = stub([page([]), page([]), page([]), page([]), page([]), envelope(record)]); + const subscriber = client(source).subscriber('user_123'); + + await subscriber.subscriptions(); + await subscriber.preferences(); + await subscriber.deliveries(); + await subscriber.timeline(); + await subscriber.runs(); + await subscriber.retrieve(); + + expect(source.calls.map((call) => call.url)).toEqual([ + 'https://api.test/v1/subscribers/user_123/subscriptions', + 'https://api.test/v1/subscribers/user_123/preferences', + 'https://api.test/v1/subscribers/user_123/deliveries', + 'https://api.test/v1/subscribers/user_123/timeline', + 'https://api.test/v1/subscribers/user_123/runs', + 'https://api.test/v1/subscribers/user_123', + ]); + }); + + it('patches preferences', async () => { + const source = stub([page([])]); + + await client(source).subscriber('user_123').updatePreferences({ 'product-updates': false }); + + expect(source.calls[0]?.method).toBe('PATCH'); + expect(source.calls[0]?.url).toBe('https://api.test/v1/subscribers/user_123/preferences'); + expect(source.calls[0]?.body).toEqual({ preferences: { 'product-updates': false } }); + }); + + it('removes the subscriber', async () => { + const source = stub([envelope({ ...record, deleted: true })]); + + const deleted = await client(source).subscriber('user_123').remove(); + + expect(source.calls[0]?.method).toBe('DELETE'); + expect(deleted.deleted).toBe(true); + }); + + it('re-identifies into a handle that carries the fresh record', async () => { + const source = stub([envelope({ ...record, attributes: { plan: 'enterprise' } })]); + + const subscriber = await client(source) + .subscriber('user_123') + .identify({ attributes: { plan: 'enterprise' } }); + + expect(subscriber.data.attributes).toEqual({ plan: 'enterprise' }); + }); + + it('keeps the tenant scope of the client it came from', async () => { + const source = stub([envelope({ id: 'msg_1' })]); + + await client(source).tenant('acme').subscriber('user_123').send({ title: 'Hey' }); + + expect(source.calls[0]?.headers['buzzkit-tenant']).toBe('acme'); + }); +}); diff --git a/packages/buzzkit/test/utils/stub.ts b/packages/buzzkit/test/utils/stub.ts new file mode 100644 index 00000000..d4533af2 --- /dev/null +++ b/packages/buzzkit/test/utils/stub.ts @@ -0,0 +1,81 @@ +type RecordedCall = { + url: string; + method: string; + headers: Record; + body: unknown; +}; + +export type Stub = { + calls: RecordedCall[]; + fetch: typeof globalThis.fetch; +}; + +export function envelope(data: unknown, init: { status?: number; headers?: Record } = {}) { + const status = init.status ?? 200; + + return new Response( + JSON.stringify({ + success: true, + data, + error: null, + metadata: { timestamp: '2026-01-01T00:00:00.000Z', requestId: 'req_stub' }, + }), + { status, headers: { 'content-type': 'application/json', 'request-id': 'req_stub', ...init.headers } } + ); +} + +export function failure( + status: number, + error: { code: string; message?: string; param?: string; details?: unknown }, + headers: Record = {} +) { + return new Response( + JSON.stringify({ + success: false, + data: null, + error: { message: `stub ${error.code}`, ...error }, + metadata: { timestamp: '2026-01-01T00:00:00.000Z' }, + }), + { status, headers: { 'content-type': 'application/json', 'request-id': 'req_stub', ...headers } } + ); +} + +export function page(items: unknown[], extra: { hasMore?: boolean; nextCursor?: string | null } = {}) { + return envelope({ + items, + hasMore: extra.hasMore ?? false, + nextCursor: extra.nextCursor ?? null, + }); +} + +export function stub(responses: Array Response | Promise)>): Stub { + const calls: RecordedCall[] = []; + const queue = [...responses]; + + const fetcher = async (url: string | URL | Request, init?: RequestInit) => { + const raw = init?.body; + calls.push({ + url: String(url), + method: init?.method ?? 'GET', + headers: (init?.headers ?? {}) as Record, + body: typeof raw === 'string' ? JSON.parse(raw) : undefined, + }); + + const next = queue.shift(); + if (!next) throw new Error(`No stub response left for ${init?.method ?? 'GET'} ${String(url)}`); + + const pending = typeof next === 'function' ? next() : next; + const { signal } = init ?? {}; + if (!signal) return await pending; + if (signal.aborted) throw signal.reason; + + return await Promise.race([ + pending, + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ]); + }; + + return { calls, fetch: fetcher as unknown as typeof globalThis.fetch }; +} diff --git a/packages/buzzkit/test/workflows/grammar.test.ts b/packages/buzzkit/test/workflows/grammar.test.ts new file mode 100644 index 00000000..3644f1f4 --- /dev/null +++ b/packages/buzzkit/test/workflows/grammar.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { SOURCE_PROVIDERS, SOURCE_STATUSES } from '../../src/sources/index'; +import { + CONCURRENCY_MODES, + DELIVERY_MODES, + FETCH_ERROR_MODES, + FETCH_METHODS, + INTERRUPTION_LEVELS, + SEND_CHANNELS, + SEND_POLICY_MODES, + SEND_PRIORITIES, + SINCE_ANCHORS, + STEP_KINDS, + TEMPLATE_FILTERS, + TRIGGER_SOURCES, +} from '../../src/workflows/index'; + +const WORKFLOW_VOCABULARIES = { + CONCURRENCY_MODES, + DELIVERY_MODES, + FETCH_ERROR_MODES, + FETCH_METHODS, + INTERRUPTION_LEVELS, + SEND_CHANNELS, + SEND_POLICY_MODES, + SEND_PRIORITIES, + SINCE_ANCHORS, + STEP_KINDS, + TEMPLATE_FILTERS, + TRIGGER_SOURCES, +}; + +describe('the workflow grammar', () => { + it('publishes every vocabulary as a non-empty list of unique lowercase names', () => { + for (const [name, values] of Object.entries(WORKFLOW_VOCABULARIES)) { + expect(values.length, name).toBeGreaterThan(0); + expect(new Set(values).size, name).toBe(values.length); + for (const value of values) expect(typeof value, `${name}.${String(value)}`).toBe('string'); + } + }); + + it('keeps the step kinds the engine dispatches on', () => { + for (const kind of ['send', 'wait', 'branch', 'fetch', 'set', 'exit']) { + expect(STEP_KINDS, kind).toContain(kind); + } + }); + + it('keeps the trigger sources the API accepts', () => { + expect([...TRIGGER_SOURCES].sort()).toEqual( + ['android', 'ios', 'server', 'system', 'web', 'webhook'].sort() + ); + }); +}); + +describe('the source grammar', () => { + it('publishes the presets and statuses', () => { + expect([...SOURCE_PROVIDERS].sort()).toEqual(['custom', 'revenuecat', 'stripe', 'superwall']); + expect([...SOURCE_STATUSES].sort()).toEqual(['active', 'paused', 'unverified']); + }); +}); diff --git a/packages/buzzkit/tsconfig.json b/packages/buzzkit/tsconfig.json index 86195577..07679a3f 100644 --- a/packages/buzzkit/tsconfig.json +++ b/packages/buzzkit/tsconfig.json @@ -12,6 +12,7 @@ "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, - "noImplicitOverride": true + "noImplicitOverride": true, + "jsx": "react-jsx" } } diff --git a/packages/buzzkit/tsdown.config.ts b/packages/buzzkit/tsdown.config.ts new file mode 100644 index 00000000..2acb500c --- /dev/null +++ b/packages/buzzkit/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: [ + 'src/index.ts', + 'src/client/index.ts', + 'src/react/index.ts', + 'src/webhooks/index.ts', + 'src/expressions/index.ts', + 'src/workflows/index.ts', + 'src/sources/index.ts', + ], + format: 'esm', + dts: true, + clean: true, + treeshake: true, + external: ['react'], +}); diff --git a/packages/buzzkit/vitest.config.ts b/packages/buzzkit/vitest.config.ts index 43e56f45..60bf8693 100644 --- a/packages/buzzkit/vitest.config.ts +++ b/packages/buzzkit/vitest.config.ts @@ -2,6 +2,28 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['test/**/*.test.ts'], + projects: [ + { + test: { + name: 'node', + include: ['test/**/*.test.ts'], + exclude: ['test/react/**'], + environment: 'node', + }, + }, + { + test: { + name: 'react', + include: ['test/react/**/*.test.tsx'], + environment: 'jsdom', + }, + }, + ], + coverage: { + include: ['src/**'], + exclude: ['src/**/index.ts'], + reporter: ['text-summary'], + thresholds: { statements: 98, branches: 95, functions: 100, lines: 98 }, + }, }, }); diff --git a/packages/database/package.json b/packages/database/package.json index a4d1e95b..93cd0d2a 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -30,6 +30,8 @@ "typescript": "^7.0.2" }, "dependencies": { + "@buzzkit/schema": "workspace:*", + "buzzkit": "workspace:*", "postgres": "^3.4.9" } } diff --git a/packages/database/src/schema/credential.ts b/packages/database/src/schema/credential.ts index 768a7de4..d1095ee0 100644 --- a/packages/database/src/schema/credential.ts +++ b/packages/database/src/schema/credential.ts @@ -1,3 +1,4 @@ +import { CREDENTIAL_STATUSES } from 'buzzkit'; import { sql } from 'drizzle-orm'; import { check, integer, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { @@ -13,7 +14,7 @@ import { } from './shared'; import { tenant } from './tenant'; -export const credentialStatus = pgEnum('credential_status', ['unvalidated', 'active', 'invalid']); +export const credentialStatus = pgEnum('credential_status', CREDENTIAL_STATUSES); export const credential = pgTable( 'credential', @@ -30,7 +31,7 @@ export const credential = pgTable( dekCiphertext: text('dek_ciphertext').notNull(), dekIv: text('dek_iv').notNull(), keyVersion: integer('key_version').notNull(), - details: jsonb('details').notNull().default({}), + details: jsonb('details').$type>().notNull().default({}), status: credentialStatus('status').notNull().default('unvalidated'), lastError: text('last_error'), validatedAt: timestamptz('validated_at'), diff --git a/packages/database/src/schema/event.ts b/packages/database/src/schema/event.ts index 00b403e8..404e259c 100644 --- a/packages/database/src/schema/event.ts +++ b/packages/database/src/schema/event.ts @@ -1,7 +1,8 @@ +import { ACTOR_TYPES } from 'buzzkit'; import { index, jsonb, pgEnum, pgTable, text } from 'drizzle-orm/pg-core'; import { bigId, bigRef, createdAt } from './shared'; -export const eventActorType = pgEnum('event_actor_type', ['member', 'user', 'key', 'system']); +export const eventActorType = pgEnum('event_actor_type', ACTOR_TYPES); export const event = pgTable( 'event', @@ -17,7 +18,7 @@ export const event = pgTable( actorDisplay: text('actor_display').notNull(), targetType: text('target_type'), targetId: text('target_id'), - data: jsonb('data'), + data: jsonb('data').$type>(), requestId: text('request_id'), ip: text('ip'), userAgent: text('user_agent'), diff --git a/packages/database/src/schema/key.ts b/packages/database/src/schema/key.ts index 1ddf649f..e9956148 100644 --- a/packages/database/src/schema/key.ts +++ b/packages/database/src/schema/key.ts @@ -1,3 +1,4 @@ +import { KEY_KINDS } from 'buzzkit'; import { sql } from 'drizzle-orm'; import { check, index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { user } from './auth'; @@ -5,7 +6,7 @@ import { bigId, bigRef, createdAt, deletedAt, timestamptz, updatedAt } from './s import { tenant } from './tenant'; import { workspace } from './workspace'; -export const apiKeyKind = pgEnum('api_key_kind', ['workspace', 'tenant', 'client']); +export const apiKeyKind = pgEnum('api_key_kind', KEY_KINDS); export const apiKey = pgTable( 'api_key', diff --git a/packages/database/src/schema/message.ts b/packages/database/src/schema/message.ts index f15e185d..8265b2a4 100644 --- a/packages/database/src/schema/message.ts +++ b/packages/database/src/schema/message.ts @@ -1,3 +1,4 @@ +import { DELIVERY_ATTEMPT_OUTCOMES, DELIVERY_STATUSES, MESSAGE_STATUSES } from 'buzzkit'; import { sql } from 'drizzle-orm'; import { check, index, integer, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { bigId, bigRef, channel, createdAt, deletedAt, provider, timestamptz, updatedAt } from './shared'; @@ -5,30 +6,11 @@ import { subscriber, subscription } from './subscriber'; import { tenant } from './tenant'; import { topic } from './topic'; -export const messageStatus = pgEnum('message_status', [ - 'queued', - 'processing', - 'completed', - 'scheduled', - 'canceled', -]); +export const messageStatus = pgEnum('message_status', MESSAGE_STATUSES); -export const deliveryStatus = pgEnum('delivery_status', [ - 'pending', - 'retrying', - 'sent', - 'delivered', - 'bounced', - 'failed', - 'invalid', -]); +export const deliveryStatus = pgEnum('delivery_status', DELIVERY_STATUSES); -export const deliveryAttemptOutcome = pgEnum('delivery_attempt_outcome', [ - 'sent', - 'retry', - 'failed', - 'invalid', -]); +export const deliveryAttemptOutcome = pgEnum('delivery_attempt_outcome', DELIVERY_ATTEMPT_OUTCOMES); export const message = pgTable( 'message', @@ -40,14 +22,14 @@ export const message = pgTable( channel: channel('channel').notNull(), topic: text('topic'), topicId: bigRef('topic_id').references(() => topic.id, { onDelete: 'restrict' }), - targets: jsonb('targets').notNull(), - payload: jsonb('payload').notNull(), + targets: jsonb('targets').$type>().notNull(), + payload: jsonb('payload').$type>().notNull(), idempotencyKey: text('idempotency_key'), idempotencyFingerprint: text('idempotency_fingerprint'), status: messageStatus('status').notNull().default('queued'), - schedule: jsonb('schedule'), + schedule: jsonb('schedule').$type<{ at: string; timezone: string; defaultTimezone?: string }>(), scheduledFor: timestamptz('scheduled_for'), - scheduledZones: jsonb('scheduled_zones'), + scheduledZones: jsonb('scheduled_zones').$type(), runId: text('run_id'), runStep: text('run_step'), canceledAt: timestamptz('canceled_at'), diff --git a/packages/database/src/schema/segment.ts b/packages/database/src/schema/segment.ts index 91c43edc..ef622d1d 100644 --- a/packages/database/src/schema/segment.ts +++ b/packages/database/src/schema/segment.ts @@ -1,3 +1,4 @@ +import type { Expression } from 'buzzkit/expressions'; import { sql } from 'drizzle-orm'; import { index, integer, jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { bigId, bigRef, createdAt, deletedAt, updatedAt } from './shared'; @@ -34,7 +35,7 @@ export const segmentVersion = pgTable( .notNull() .references(() => segment.id, { onDelete: 'cascade' }), version: integer('version').notNull(), - expression: jsonb('expression').notNull(), + expression: jsonb('expression').$type().notNull(), createdAt: createdAt(), }, (table) => [uniqueIndex('segment_version_unique').on(table.segmentId, table.version)] diff --git a/packages/database/src/schema/shared.ts b/packages/database/src/schema/shared.ts index 2720d90d..22810936 100644 --- a/packages/database/src/schema/shared.ts +++ b/packages/database/src/schema/shared.ts @@ -1,10 +1,11 @@ +import { CHANNELS, ENVIRONMENTS, PROVIDERS } from 'buzzkit'; import { bigint, pgEnum, timestamp } from 'drizzle-orm/pg-core'; -export const channel = pgEnum('channel', ['push', 'email']); +export const channel = pgEnum('channel', CHANNELS); -export const provider = pgEnum('provider', ['apns', 'fcm', 'resend']); +export const provider = pgEnum('provider', PROVIDERS); -export const environment = pgEnum('environment', ['production', 'sandbox']); +export const environment = pgEnum('environment', ENVIRONMENTS); export const timestamptz = (name: string) => timestamp(name, { withTimezone: true, mode: 'date' }); diff --git a/packages/database/src/schema/source.ts b/packages/database/src/schema/source.ts index 30732280..fbc3f64e 100644 --- a/packages/database/src/schema/source.ts +++ b/packages/database/src/schema/source.ts @@ -1,18 +1,14 @@ +import { SOURCE_DELIVERY_OUTCOMES } from 'buzzkit'; +import { SOURCE_STATUSES } from 'buzzkit/sources'; import { sql } from 'drizzle-orm'; import { index, integer, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { bigId, bigRef, createdAt, deletedAt, timestamptz, updatedAt } from './shared'; import { subscriber } from './subscriber'; import { tenant } from './tenant'; -export const sourceStatus = pgEnum('source_status', ['unverified', 'active', 'paused']); +export const sourceStatus = pgEnum('source_status', SOURCE_STATUSES); -export const sourceDeliveryOutcome = pgEnum('source_delivery_outcome', [ - 'event', - 'duplicate', - 'dropped', - 'rejected', - 'unverified', -]); +export const sourceDeliveryOutcome = pgEnum('source_delivery_outcome', SOURCE_DELIVERY_OUTCOMES); export const source = pgTable( 'source', @@ -24,8 +20,11 @@ export const source = pgTable( name: text('name').notNull(), provider: text('provider').notNull(), status: sourceStatus('status').notNull().default('unverified'), - verification: jsonb('verification').notNull().default({ scheme: 'header', header: 'x-buzzkit-secret' }), - mapping: jsonb('mapping').notNull(), + verification: jsonb('verification') + .$type>() + .notNull() + .default({ scheme: 'header', header: 'x-buzzkit-secret' }), + mapping: jsonb('mapping').$type>().notNull(), secretCiphertext: text('secret_ciphertext'), secretIv: text('secret_iv'), dekCiphertext: text('dek_ciphertext'), diff --git a/packages/database/src/schema/subscriber.ts b/packages/database/src/schema/subscriber.ts index 091b43e0..877286a3 100644 --- a/packages/database/src/schema/subscriber.ts +++ b/packages/database/src/schema/subscriber.ts @@ -1,10 +1,11 @@ +import { PLATFORMS, SUBSCRIPTION_STATUSES } from 'buzzkit'; import { sql } from 'drizzle-orm'; import { boolean, check, index, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { bigId, bigRef, channel, createdAt, deletedAt, environment, timestamptz, updatedAt } from './shared'; import { tenant } from './tenant'; -export const subscriptionPlatform = pgEnum('subscription_platform', ['ios', 'android']); -export const subscriptionStatus = pgEnum('subscription_status', ['active', 'invalid']); +export const subscriptionPlatform = pgEnum('subscription_platform', PLATFORMS); +export const subscriptionStatus = pgEnum('subscription_status', SUBSCRIPTION_STATUSES); export const subscriber = pgTable( 'subscriber', @@ -14,7 +15,7 @@ export const subscriber = pgTable( .notNull() .references(() => tenant.id, { onDelete: 'cascade' }), externalId: text('external_id').notNull(), - attributes: jsonb('attributes').notNull().default({}), + attributes: jsonb('attributes').$type>().notNull().default({}), identityVerifiedAt: timestamptz('identity_verified_at'), createdAt: createdAt(), updatedAt: updatedAt(), diff --git a/packages/database/src/schema/tenant.ts b/packages/database/src/schema/tenant.ts index abfb12a1..51a0b583 100644 --- a/packages/database/src/schema/tenant.ts +++ b/packages/database/src/schema/tenant.ts @@ -14,8 +14,8 @@ export const tenant = pgTable( slug: text('slug').notNull(), isDefault: boolean('is_default').notNull().default(false), identitySecret: text('identity_secret'), - settings: jsonb('settings').notNull().default({}), - metadata: jsonb('metadata').notNull().default({}), + settings: jsonb('settings').$type>().notNull().default({}), + metadata: jsonb('metadata').$type>().notNull().default({}), createdAt: createdAt(), updatedAt: updatedAt(), deletedAt: deletedAt(), diff --git a/packages/database/src/schema/topic.ts b/packages/database/src/schema/topic.ts index 726615ad..74f4c01f 100644 --- a/packages/database/src/schema/topic.ts +++ b/packages/database/src/schema/topic.ts @@ -36,7 +36,10 @@ export const topic = pgTable( categoryId: bigRef('category_id').references(() => topicCategory.id, { onDelete: 'set null' }), dailyCap: integer('daily_cap'), defaultOptedIn: boolean('default_opted_in').notNull().default(true), - channelDefaults: jsonb('channel_defaults').notNull().default({}), + channelDefaults: jsonb('channel_defaults') + .$type>>() + .notNull() + .default({}), channels: channel('channels').array().notNull().default(sql`'{push,email}'::channel[]`), createdAt: createdAt(), updatedAt: updatedAt(), diff --git a/packages/database/src/schema/webhook.ts b/packages/database/src/schema/webhook.ts index 9fcd5d56..d850392c 100644 --- a/packages/database/src/schema/webhook.ts +++ b/packages/database/src/schema/webhook.ts @@ -1,17 +1,13 @@ +import { WEBHOOK_DELIVERY_STATUSES, WEBHOOK_EVENT_SOURCES } from 'buzzkit'; import { index, integer, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { user } from './auth'; import { bigId, bigRef, createdAt, deletedAt, timestamptz, updatedAt } from './shared'; import { tenant } from './tenant'; import { workspace } from './workspace'; -export const webhookEventSource = pgEnum('webhook_event_source', ['audit', 'stream']); +export const webhookEventSource = pgEnum('webhook_event_source', WEBHOOK_EVENT_SOURCES); -export const webhookDeliveryStatus = pgEnum('webhook_delivery_status', [ - 'pending', - 'success', - 'failed', - 'exhausted', -]); +export const webhookDeliveryStatus = pgEnum('webhook_delivery_status', WEBHOOK_DELIVERY_STATUSES); export const webhookEndpoint = pgTable( 'webhook_endpoint', @@ -50,7 +46,7 @@ export const webhookEvent = pgTable( source: webhookEventSource('source').notNull(), sourceId: text('source_id').notNull(), type: text('type').notNull(), - payload: jsonb('payload').notNull(), + payload: jsonb('payload').$type>().notNull(), createdAt: createdAt(), }, (table) => [ diff --git a/packages/database/src/schema/workflow.ts b/packages/database/src/schema/workflow.ts index 2bb61ff0..a560fd75 100644 --- a/packages/database/src/schema/workflow.ts +++ b/packages/database/src/schema/workflow.ts @@ -1,9 +1,11 @@ +import type { WorkflowSpec } from '@buzzkit/schema/workflows'; +import { WORKFLOW_STATUSES } from 'buzzkit'; import { sql } from 'drizzle-orm'; import { index, integer, jsonb, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { bigId, bigRef, createdAt, deletedAt, timestamptz, updatedAt } from './shared'; import { tenant } from './tenant'; -export const workflowStatus = pgEnum('workflow_status', ['draft', 'active', 'paused']); +export const workflowStatus = pgEnum('workflow_status', WORKFLOW_STATUSES); export const workflow = pgTable( 'workflow', @@ -37,7 +39,7 @@ export const workflowVersion = pgTable( .notNull() .references(() => workflow.id, { onDelete: 'cascade' }), version: integer('version').notNull(), - spec: jsonb('spec').notNull(), + spec: jsonb('spec').$type().notNull(), publishedAt: timestamptz('published_at'), createdAt: createdAt(), }, diff --git a/packages/database/src/schema/workspace.ts b/packages/database/src/schema/workspace.ts index b7103e93..c2d5f054 100644 --- a/packages/database/src/schema/workspace.ts +++ b/packages/database/src/schema/workspace.ts @@ -1,9 +1,10 @@ +import { MEMBER_ROLES } from 'buzzkit'; import { sql } from 'drizzle-orm'; import { index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { user } from './auth'; import { bigId, bigRef, createdAt, deletedAt, updatedAt } from './shared'; -export const workspaceMemberRole = pgEnum('workspace_member_role', ['member', 'admin', 'owner']); +export const workspaceMemberRole = pgEnum('workspace_member_role', MEMBER_ROLES); export const workspace = pgTable( 'workspace', diff --git a/packages/schema/CLAUDE.md b/packages/schema/CLAUDE.md index 8b654496..195877c9 100644 --- a/packages/schema/CLAUDE.md +++ b/packages/schema/CLAUDE.md @@ -2,10 +2,11 @@ Private. Grammars that two sides of the platform must agree on, one subpath per grammar: `@buzzkit/schema/workflows`, `@buzzkit/schema/sources` and `@buzzkit/schema/imports`. Everything in here is used by both the API (to validate a document before it accepts it) and the dashboard (to lint as you type and to draw the definition); anything only one side needs lives with that side. +**This package holds behavior, not types.** The workflow spec and source mapping *types* — and the vocabularies they derive from (`STEP_KINDS`, `TRIGGER_SOURCES`, `SOURCE_PROVIDERS`, …) — describe public API request bodies, so they live in the published `buzzkit` package (`buzzkit/workflows`, `buzzkit/sources`) and the SDK types `workflows.create({ spec })` with them. Each subpath's `index.ts` re-exports them in one line (`export * from 'buzzkit/workflows'`), so `@buzzkit/schema/workflows` stays the single import for internal consumers while there is exactly one definition — there are no pass-through `types.ts` / `constants.ts` files, and code inside this package imports the moved names straight from `buzzkit/*`. What stays defined here is everything that *runs*: the lints, the parsers, the evaluators, the presets, and the validation limits (`MAX_STEPS`, `STEP_NAME_PATTERN`) that only the lint enforces. The dependency runs one way — `buzzkit` ← `@buzzkit/schema` ← `apps/*` — so never import `@buzzkit/schema` from `buzzkit`. + ``` src/workflows/ - types.ts The spec: triggers, steps, expressions, what each key takes - constants.ts Limits, patterns and vocabularies (step kinds, filters, sources) + constants.ts The lint's own limits and patterns (MAX_STEPS, STEP_NAME_PATTERN); the vocabularies live in buzzkit lint/index.ts lintWorkflow: every problem with a path and a sentence; isWorkflowSpec, workflowProblem lint/conditions.ts The run-only conditions (occurred, opened, delivered, since) plugged into buzzkit/expressions' lint parse/template.ts Placeholders, filters and the ternary: parseTemplate, lintTemplate, templatePaths @@ -18,6 +19,6 @@ src/imports/ Bulk imports (docs/api/imports.md): parseCsv, IMPORT_PRESETS POST /v1/imports takes and the dashboard's Import subscribers dialog sends ``` -Nothing here runs a workflow: rendering templates (`engine/template.ts`), the next fire of a schedule (`api/workflows/cron.ts`), zone arithmetic (`libs/timezone.ts`), evaluating conditions (`actor/evaluate.ts`) and the TypeBox request schemas (`api/workflows/schema.ts`, `api/segments/schema.ts`) are the API's. Nothing here is public: workflows are defined in the dashboard or through the API, never from customer code, so the public `buzzkit` SDK holds none of this. The expression grammar the workflow language extends (`ref`, `count`, `never`, groups) is public in `buzzkit/expressions` because inline segments on a send are. +Nothing here runs a workflow: rendering templates (`engine/template.ts`), the next fire of a schedule (`api/workflows/cron.ts`), zone arithmetic (`libs/timezone.ts`), evaluating conditions (`actor/evaluate.ts`) and the TypeBox request schemas (`api/workflows/schema.ts`, `api/segments/schema.ts`) are the API's. Behavior here is not public: workflows are still defined in the dashboard or through the API, never from customer code. The spec *shape* is public, because `POST /v1/workflows` takes it and the SDK types `workflows.create({ spec })` with it. The expression grammar the workflow language extends (`ref`, `count`, `never`, groups) is public in `buzzkit/expressions` because inline segments on a send are. Same rules as the SDK package: no comments, names written out, runtime-neutral web platform APIs only, tests mirror `src/` in `test/`, `bun run test` here. diff --git a/packages/schema/src/imports/constants.ts b/packages/schema/src/imports/constants.ts index 1c36d083..04d13204 100644 --- a/packages/schema/src/imports/constants.ts +++ b/packages/schema/src/imports/constants.ts @@ -2,7 +2,7 @@ export const IMPORT_PROVIDERS = ['onesignal', 'custom'] as const; export const IMPORT_CHANNELS = ['push', 'email', 'sms', 'web'] as const; -export const AVAILABLE_CHANNELS = ['push', 'email'] as const; +export { CHANNELS as AVAILABLE_CHANNELS } from 'buzzkit'; export const IMPORT_TARGETS = [ { diff --git a/packages/schema/src/sources/index.ts b/packages/schema/src/sources/index.ts index 3b7dfc4b..6b58de4b 100644 --- a/packages/schema/src/sources/index.ts +++ b/packages/schema/src/sources/index.ts @@ -1,4 +1,4 @@ -export * from './constants'; +export * from 'buzzkit/sources'; export { evaluatePayload } from './evaluate'; export { isSourceMapping, @@ -11,4 +11,3 @@ export { mapPayload } from './map'; export { isPayloadPath, listPaths, readPath } from './paths'; export { detectProvider, SOURCE_PRESETS } from './presets'; export { suggestMapping } from './suggest'; -export type * from './types'; diff --git a/packages/schema/src/sources/lint.ts b/packages/schema/src/sources/lint.ts index b5a28e82..a43f878c 100644 --- a/packages/schema/src/sources/lint.ts +++ b/packages/schema/src/sources/lint.ts @@ -1,13 +1,13 @@ import { EVENT_NAME_PATTERN, lintExpression } from 'buzzkit/expressions'; +import type { SourceMapping, Verification } from 'buzzkit/sources'; import { HEADER_NAME_PATTERN, MAX_MAPPED_EVENTS, MAX_PICKED_PATHS, PASSTHROUGH, VERIFICATION_SCHEMES, -} from './constants'; +} from 'buzzkit/sources'; import { isPayloadPath } from './paths'; -import type { SourceMapping, Verification } from './types'; export type MappingProblem = { path: (string | number)[]; message: string }; diff --git a/packages/schema/src/sources/map.ts b/packages/schema/src/sources/map.ts index f73998ec..79f28f29 100644 --- a/packages/schema/src/sources/map.ts +++ b/packages/schema/src/sources/map.ts @@ -1,7 +1,7 @@ -import { PASSTHROUGH } from './constants'; +import type { MappingOutcome, SourceMapping } from 'buzzkit/sources'; +import { PASSTHROUGH } from 'buzzkit/sources'; import { evaluatePayload } from './evaluate'; import { readPath } from './paths'; -import type { MappingOutcome, SourceMapping } from './types'; function text(value: unknown): string | null { if (typeof value === 'string' && value.length > 0) return value; diff --git a/packages/schema/src/sources/paths.ts b/packages/schema/src/sources/paths.ts index 565f0b63..08ac72c9 100644 --- a/packages/schema/src/sources/paths.ts +++ b/packages/schema/src/sources/paths.ts @@ -1,4 +1,4 @@ -import { PAYLOAD_PATH_PATTERN } from './constants'; +import { PAYLOAD_PATH_PATTERN } from 'buzzkit/sources'; export function isPayloadPath(path: unknown): path is string { return typeof path === 'string' && PAYLOAD_PATH_PATTERN.test(path); diff --git a/packages/schema/src/sources/presets.ts b/packages/schema/src/sources/presets.ts index 88d66ab3..e7ac26ba 100644 --- a/packages/schema/src/sources/presets.ts +++ b/packages/schema/src/sources/presets.ts @@ -1,6 +1,6 @@ -import { GENERIC_SECRET_HEADER, SVIX_HEADERS } from './constants'; +import type { SourcePreset, SourceProvider } from 'buzzkit/sources'; +import { GENERIC_SECRET_HEADER, SVIX_HEADERS } from 'buzzkit/sources'; import { readPath } from './paths'; -import type { SourcePreset, SourceProvider } from './types'; export const SOURCE_PRESETS: Record = { stripe: { diff --git a/packages/schema/src/sources/suggest.ts b/packages/schema/src/sources/suggest.ts index f445f12b..7a5db954 100644 --- a/packages/schema/src/sources/suggest.ts +++ b/packages/schema/src/sources/suggest.ts @@ -1,6 +1,6 @@ +import type { MappingSuggestions, Suggestion } from 'buzzkit/sources'; import { listPaths, readPath } from './paths'; import { detectProvider, SOURCE_PRESETS } from './presets'; -import type { MappingSuggestions, Suggestion } from './types'; const TYPE_KEYS = ['type', 'event', 'event_type', 'eventType', 'name', 'action']; diff --git a/packages/schema/src/workflows/constants.ts b/packages/schema/src/workflows/constants.ts index 19586d6c..fcbbe4db 100644 --- a/packages/schema/src/workflows/constants.ts +++ b/packages/schema/src/workflows/constants.ts @@ -20,29 +20,6 @@ export const MAX_EXPECTED_STATUSES = 20; export const WALL_TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; -export const CONCURRENCY_MODES = ['per-event', 'one-per-subscriber'] as const; - -export const TRIGGER_SOURCES = ['server', 'ios', 'android', 'web', 'system', 'webhook'] as const; - -export const SEND_CHANNELS = ['push'] as const; - -export const DELIVERY_MODES = ['push', 'local'] as const; - -export const STEP_KINDS = [ - 'wait', - 'waitUntil', - 'waitFor', - 'repeat', - 'forEach', - 'branch', - 'fetch', - 'set', - 'send', - 'exit', -] as const; - -export const SINCE_ANCHORS = ['trigger', 'localMidnight', 'iteration'] as const; - export const MIN_REPEAT_PASSES = 2; export const MAX_REPEAT_PASSES = 30; @@ -53,20 +30,10 @@ export const MAX_WAIT_EVENTS = 5; export const MAX_SEND_ACTIONS = 4; -export const INTERRUPTION_LEVELS = ['passive', 'active', 'timeSensitive', 'critical'] as const; - -export const SEND_PRIORITIES = ['high', 'normal'] as const; - -export const SEND_POLICY_MODES = ['ignore'] as const; - export const FOREACH_ITEM_ROOTS = ['vars', 'steps', 'trigger', 'subscriber'] as const; export const WORKFLOW_CONDITIONS = ['ref', 'count', 'never', 'occurred', 'opened', 'delivered'] as const; -export const FETCH_ERROR_MODES = ['fail', 'skip', 'continue'] as const; - -export const FETCH_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; - export const SECRET_NAME_PATTERN = /^[a-z][A-Za-z0-9_]{0,47}$/; export const FETCH_TIMEOUT_PATTERN = /^(\d{1,2})s$/; @@ -83,41 +50,6 @@ export const SYSTEM_ATTRIBUTE_PREFIX = '$'; export const SEGMENT_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -export const TEMPLATE_FILTERS = [ - 'default', - 'upcase', - 'downcase', - 'capitalize', - 'strip', - 'truncate', - 'append', - 'prepend', - 'replace', - 'pluralize', - 'size', - 'first', - 'last', - 'join', - 'url_encode', - 'json', - 'number', - 'round', - 'ceil', - 'floor', - 'abs', - 'plus', - 'minus', - 'times', - 'divided_by', - 'modulo', - 'at_least', - 'at_most', - 'date', - 'time', - 'until', - 'ago', -] as const; - export const DATE_STYLES = ['full', 'long', 'medium', 'short', 'weekday'] as const; export const DURATION_STYLES = ['long', 'short'] as const; diff --git a/packages/schema/src/workflows/index.ts b/packages/schema/src/workflows/index.ts index 36e69c57..ff99cd09 100644 --- a/packages/schema/src/workflows/index.ts +++ b/packages/schema/src/workflows/index.ts @@ -1,4 +1,5 @@ export type { Duration } from 'buzzkit/expressions'; +export * from 'buzzkit/workflows'; export * from './constants'; export { WORKFLOW_CHECKERS } from './lint/conditions'; export { formatWorkflowPath, isWorkflowSpec, lintWorkflow, workflowProblem } from './lint/index'; @@ -25,4 +26,3 @@ export { templatePaths, } from './parse/template'; export { isTimezone } from './parse/timezone'; -export type * from './types'; diff --git a/packages/schema/src/workflows/lint/conditions.ts b/packages/schema/src/workflows/lint/conditions.ts index 9886b4be..44dc2f40 100644 --- a/packages/schema/src/workflows/lint/conditions.ts +++ b/packages/schema/src/workflows/lint/conditions.ts @@ -4,7 +4,8 @@ import { type ExpressionPath, type LintTools, } from 'buzzkit/expressions'; -import { SINCE_ANCHORS, STEP_NAME_MAX_LENGTH, STEP_NAME_PATTERN } from '../constants'; +import { SINCE_ANCHORS } from 'buzzkit/workflows'; +import { STEP_NAME_MAX_LENGTH, STEP_NAME_PATTERN } from '../constants'; function checkWindow(path: ExpressionPath, node: Record, label: string, tools: LintTools) { if (node.within !== undefined) tools.checkDuration([...path, 'within'], node.within); diff --git a/packages/schema/src/workflows/lint/index.ts b/packages/schema/src/workflows/lint/index.ts index d2759ed6..748fda57 100644 --- a/packages/schema/src/workflows/lint/index.ts +++ b/packages/schema/src/workflows/lint/index.ts @@ -8,15 +8,23 @@ import { list, type RefScope, } from 'buzzkit/expressions'; +import type { WorkflowIssue, WorkflowSpec } from 'buzzkit/workflows'; import { CONCURRENCY_MODES, DELIVERY_MODES, - FALLBACK_CASE, FETCH_ERROR_MODES, FETCH_METHODS, + INTERRUPTION_LEVELS, + SEND_CHANNELS, + SEND_POLICY_MODES, + SEND_PRIORITIES, + STEP_KINDS, + TRIGGER_SOURCES, +} from 'buzzkit/workflows'; +import { + FALLBACK_CASE, FETCH_TIMEOUT_PATTERN, FOREACH_ITEM_ROOTS, - INTERRUPTION_LEVELS, MAX_BRANCH_CASES, MAX_BRANCH_DEPTH, MAX_EXPECTED_STATUSES, @@ -34,15 +42,10 @@ import { RESERVED_EVENT_PREFIX, SECRET_NAME_PATTERN, SEGMENT_SLUG_PATTERN, - SEND_CHANNELS, - SEND_POLICY_MODES, - SEND_PRIORITIES, - STEP_KINDS, STEP_NAME_MAX_LENGTH, STEP_NAME_PATTERN, SUBSCRIBER_TIMEZONE, SYSTEM_ATTRIBUTE_PREFIX, - TRIGGER_SOURCES, VAR_NAME_PATTERN, WALL_TIME_PATTERN, WORKFLOW_CONDITIONS, @@ -51,7 +54,6 @@ import { cronProblem } from '../parse/cron'; import { durationSeconds, isDuration } from '../parse/duration'; import { lintTemplate, templatePaths } from '../parse/template'; import { isTimezone } from '../parse/timezone'; -import type { WorkflowIssue, WorkflowSpec } from '../types'; import { WORKFLOW_CHECKERS } from './conditions'; const TRIGGER_REFS: RefScope = { roots: ['trigger', 'subscriber'], bare: [], label: 'a trigger' }; diff --git a/packages/schema/src/workflows/parse/cron.ts b/packages/schema/src/workflows/parse/cron.ts index ffb11696..2b6a82d7 100644 --- a/packages/schema/src/workflows/parse/cron.ts +++ b/packages/schema/src/workflows/parse/cron.ts @@ -1,5 +1,5 @@ +import type { Schedule } from 'buzzkit/workflows'; import { WALL_TIME_PATTERN } from '../constants'; -import type { Schedule } from '../types'; export type CronFields = { minutes: number[]; diff --git a/packages/schema/src/workflows/parse/template.ts b/packages/schema/src/workflows/parse/template.ts index 24cf62e0..7918ebb0 100644 --- a/packages/schema/src/workflows/parse/template.ts +++ b/packages/schema/src/workflows/parse/template.ts @@ -1,5 +1,6 @@ -import { DATE_STYLES, DURATION_STYLES, TEMPLATE_FILTERS } from '../constants'; -import type { TemplateFilter } from '../types'; +import type { TemplateFilter } from 'buzzkit/workflows'; +import { TEMPLATE_FILTERS } from 'buzzkit/workflows'; +import { DATE_STYLES, DURATION_STYLES } from '../constants'; import { isDuration } from './duration'; export type TemplateIssue = { placeholder: string; message: string }; diff --git a/turbo.json b/turbo.json index 443580bd..e5f84427 100644 --- a/turbo.json +++ b/turbo.json @@ -35,6 +35,10 @@ "test": { "dependsOn": ["^build"], "cache": false + }, + "buzzkit#build": { + "inputs": ["src/**/*.ts", "tsdown.config.ts", "package.json", "tsconfig.json"], + "outputs": ["dist/**"] } } }