Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
TEST_DATABASE_URL: postgresql://postgres:[email protected]:5432/hooky_test
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: hooky_test
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d hooky_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Check out repository
uses: actions/checkout@v4
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ The product plan and implementation decisions live in [PRD issue #1](https://git

## Repository

This Bun workspace currently contains the Next.js web application in `apps/web`. Additional database, relay-core, shared, CLI, and test-support packages will be introduced with the vertical slices that need them.
This Bun workspace currently contains:

- `apps/web`: the Next.js web application and API surface.
- `packages/database`: the Neon/PostgreSQL schema, migrations, and durable delivery state machine.

Relay-core, shared, CLI, and test-support packages will be introduced with the vertical slices that need them.

## Development

Expand All @@ -19,6 +24,24 @@ bun run dev

The application runs at [http://localhost:3000](http://localhost:3000), with its health endpoint at [http://localhost:3000/api/health](http://localhost:3000/api/health).

## Database

Application traffic must use Neon's pooled connection string through `DATABASE_URL`. The database package keeps captured webhook events immutable and uses short PostgreSQL statements with `FOR UPDATE SKIP LOCKED` for concurrent delivery claims.

Generate a migration after changing the Drizzle schema:

```bash
bun run db:generate
```

Apply committed migrations to the database in `DATABASE_URL`:

```bash
bun run db:migrate
```

Database integration tests use `TEST_DATABASE_URL` when supplied. Otherwise, they start and remove an ephemeral local PostgreSQL cluster with `initdb` and `pg_ctl`.

## Validation

```bash
Expand Down
2 changes: 2 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Use the pooled Neon connection string for application traffic.
DATABASE_URL=postgresql://user:[email protected]/database?sslmode=require
240 changes: 234 additions & 6 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
],
"scripts": {
"build": "turbo run build",
"db:generate": "bun run --cwd packages/database generate",
"db:migrate": "bun run --cwd packages/database migrate",
"dev": "turbo run dev --filter=@hooky/web",
"lint": "turbo run lint",
"prettier": "prettier --write .",
Expand Down
14 changes: 14 additions & 0 deletions packages/database/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig } from "drizzle-kit";

export default defineConfig({
schema: "./src/schema.ts",
out: "./migrations",
dialect: "postgresql",
dbCredentials: {
url:
process.env.DATABASE_URL ??
"postgresql://postgres:[email protected]:5432/hooky",
},
strict: true,
verbose: true,
});
13 changes: 13 additions & 0 deletions packages/database/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import tseslint from "typescript-eslint";

export default tseslint.config(
{
ignores: ["dist/**", "migrations/**"],
},
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/consistent-type-imports": "error",
},
},
);
85 changes: 85 additions & 0 deletions packages/database/migrations/0000_colossal_cardiac.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";--> statement-breakpoint
CREATE TYPE "public"."delivery_attempt_outcome" AS ENUM('delivered', 'failed', 'expired');--> statement-breakpoint
CREATE TYPE "public"."delivery_status" AS ENUM('pending', 'in_flight', 'delivered', 'dead');--> statement-breakpoint
CREATE TYPE "public"."hook_state" AS ENUM('active', 'disabled');--> statement-breakpoint
CREATE TABLE "accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "deliveries" (
"id" uuid PRIMARY KEY NOT NULL,
"account_id" uuid NOT NULL,
"hook_id" uuid NOT NULL,
"event_id" uuid NOT NULL,
"status" "delivery_status" DEFAULT 'pending' NOT NULL,
"attempt_count" integer DEFAULT 0 NOT NULL,
"available_at" timestamp with time zone NOT NULL,
"leased_by" text,
"lease_token_hash" text,
"leased_until" timestamp with time zone,
"delivered_at" timestamp with time zone,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "deliveries_account_id_id_unique" UNIQUE("account_id","id"),
CONSTRAINT "deliveries_event_id_unique" UNIQUE("event_id")
);
--> statement-breakpoint
CREATE TABLE "delivery_attempts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"account_id" uuid NOT NULL,
"delivery_id" uuid NOT NULL,
"attempt_number" integer NOT NULL,
"listener_id" text NOT NULL,
"lease_token_hash" text NOT NULL,
"outcome" "delivery_attempt_outcome",
"error" text,
"started_at" timestamp with time zone NOT NULL,
"finished_at" timestamp with time zone,
CONSTRAINT "delivery_attempts_delivery_number_unique" UNIQUE("delivery_id","attempt_number")
);
--> statement-breakpoint
CREATE TABLE "hooks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"account_id" uuid NOT NULL,
"name" text NOT NULL,
"state" "hook_state" DEFAULT 'active' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hooks_account_id_id_unique" UNIQUE("account_id","id")
);
--> statement-breakpoint
CREATE TABLE "webhook_events" (
"id" uuid PRIMARY KEY NOT NULL,
"account_id" uuid NOT NULL,
"hook_id" uuid NOT NULL,
"request_method" text NOT NULL,
"request_path" text NOT NULL,
"query" jsonb DEFAULT '{}'::jsonb NOT NULL,
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
"body" bytea NOT NULL,
"body_sha256" text NOT NULL,
"received_at" timestamp with time zone NOT NULL,
CONSTRAINT "webhook_events_account_id_id_unique" UNIQUE("account_id","id")
);
--> statement-breakpoint
ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_account_hook_fk" FOREIGN KEY ("account_id","hook_id") REFERENCES "public"."hooks"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_account_event_fk" FOREIGN KEY ("account_id","event_id") REFERENCES "public"."webhook_events"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "delivery_attempts" ADD CONSTRAINT "delivery_attempts_account_delivery_fk" FOREIGN KEY ("account_id","delivery_id") REFERENCES "public"."deliveries"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hooks" ADD CONSTRAINT "hooks_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_events" ADD CONSTRAINT "webhook_events_account_hook_fk" FOREIGN KEY ("account_id","hook_id") REFERENCES "public"."hooks"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "deliveries_claim_index" ON "deliveries" USING btree ("account_id","hook_id","status","available_at","leased_until");--> statement-breakpoint
CREATE INDEX "delivery_attempts_account_delivery_index" ON "delivery_attempts" USING btree ("account_id","delivery_id");--> statement-breakpoint
CREATE UNIQUE INDEX "hooks_account_id_name_unique" ON "hooks" USING btree ("account_id","name");--> statement-breakpoint
CREATE INDEX "webhook_events_hook_received_at_index" ON "webhook_events" USING btree ("account_id","hook_id","received_at");--> statement-breakpoint
CREATE FUNCTION reject_webhook_event_update() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'webhook events are immutable';
END;
$$ LANGUAGE plpgsql;--> statement-breakpoint
CREATE TRIGGER webhook_events_immutable
BEFORE UPDATE ON webhook_events
FOR EACH ROW
EXECUTE FUNCTION reject_webhook_event_update();
Loading
Loading