Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
5 changes: 5 additions & 0 deletions .changeset/tender-pugs-invite.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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).

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/api/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<resource>/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/<resource>` (domain) or `src/libs` (infrastructure).
- **Thin handlers:** domain logic lives in `src/api/<resource>/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/<resource>` (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.
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/api/credentials/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function replaceCredential(
provider: CredentialProvider;
environment: CredentialEnvironment;
secret: string;
details: Record<string, unknown>;
details: Record<string, string>;
outcome: ValidationOutcome;
}
): Promise<Credential> {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/api/credentials/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export async function revalidateCredential(db: Db, credential: Credential): Prom
return updated!;
}

const details = credential.details as Record<string, string>;
const { details } = credential;
let outcome: ValidationOutcome;
try {
outcome = await validateCredentialUpload(credential.provider, {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/api/deliveries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/api/deliveries/schemas.ts
Original file line number Diff line number Diff line change
@@ -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)),
});
2 changes: 1 addition & 1 deletion apps/api/src/api/events/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
13 changes: 12 additions & 1 deletion apps/api/src/api/events/schemas.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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),
Expand Down Expand Up @@ -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),
});
2 changes: 1 addition & 1 deletion apps/api/src/api/events/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ async function promoteReceipts(
}

export function subscriberAttributes(subscriber: Pick<Subscriber, 'attributes'>): Record<string, unknown> {
return (subscriber.attributes ?? {}) as Record<string, unknown>;
return subscriber.attributes;
}

function resolveTrackedEvent(event: EventInput, source: EventSource, now: Date): ActorEventInput {
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/api/events/types.ts
Original file line number Diff line number Diff line change
@@ -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];

Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/api/messages/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
});
2 changes: 1 addition & 1 deletion apps/api/src/api/messages/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
id: credential.id,
updatedAt: credential.updatedAt,
environment: credential.environment,
details: credential.details as Record<string, string>,
details: credential.details,
secret: await decryptCredentialSecret(credential),
};
}
Expand Down Expand Up @@ -134,7 +134,7 @@
memo: CredentialMemo,
tokens: TokenMemo
): Promise<ProcessedDelivery[]> {
return await trace('deliveries.processBatch', { 'deliveries.count': jobs.length }, async (t) => {

Check warning on line 137 in apps/api/src/api/messages/send.ts

View workflow job for this annotation

GitHub Actions / lint

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 92 detected (max: 25).
const rows = new Map(
(
await listDeliveriesForProcessing(
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/api/runs/constants.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/api/runs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
} from './types';

export * from './constants';
export * from './schemas';
export * from './serialize';
export * from './types';

Expand Down
Loading
Loading