@hogsend/db 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -127,6 +127,20 @@
127
127
  "when": 1780841637175,
128
128
  "tag": "0017_long_spacker_dave",
129
129
  "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "7",
134
+ "when": 1780855021994,
135
+ "tag": "0018_webhooks",
136
+ "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1780907482971,
142
+ "tag": "0019_flippant_songbird",
143
+ "breakpoints": true
130
144
  }
131
145
  ]
132
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/db",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -50,3 +50,11 @@ export const bucketMembershipStatusEnum = pgEnum("bucket_membership_status", [
50
50
  "active",
51
51
  "left",
52
52
  ]);
53
+
54
+ export const webhookDeliveryStatusEnum = pgEnum("webhook_delivery_status", [
55
+ "pending", // enqueued, awaiting first attempt OR a scheduled retry (nextRetryAt)
56
+ "sending", // a delivery run CAS'd the row and is mid-POST (orphan-recovery sentinel)
57
+ "delivered", // 2xx received — TERMINAL
58
+ "failed", // attempts exhausted — TERMINAL, mirrored to dead_letter_queue
59
+ "discarded", // endpoint disabled/deleted mid-flight — TERMINAL, NOT an error, NOT dead-lettered
60
+ ]);
@@ -20,3 +20,5 @@ export * from "./link-clicks.js";
20
20
  export * from "./relations.js";
21
21
  export * from "./tracked-links.js";
22
22
  export * from "./user-events.js";
23
+ export * from "./webhook-deliveries.js";
24
+ export * from "./webhook-endpoints.js";
@@ -25,6 +25,8 @@ import { journeyStates } from "./journey-states.js";
25
25
  import { linkClicks } from "./link-clicks.js";
26
26
  import { trackedLinks } from "./tracked-links.js";
27
27
  import { userEvents } from "./user-events.js";
28
+ import { webhookDeliveries } from "./webhook-deliveries.js";
29
+ import { webhookEndpoints } from "./webhook-endpoints.js";
28
30
 
29
31
  export const alertRulesRelations = relations(alertRules, ({ many }) => ({
30
32
  history: many(alertHistory),
@@ -142,6 +144,23 @@ export const linkClicksRelations = relations(linkClicks, ({ one }) => ({
142
144
  }),
143
145
  }));
144
146
 
147
+ export const webhookEndpointsRelations = relations(
148
+ webhookEndpoints,
149
+ ({ many }) => ({
150
+ deliveries: many(webhookDeliveries),
151
+ }),
152
+ );
153
+
154
+ export const webhookDeliveriesRelations = relations(
155
+ webhookDeliveries,
156
+ ({ one }) => ({
157
+ endpoint: one(webhookEndpoints, {
158
+ fields: [webhookDeliveries.endpointId],
159
+ references: [webhookEndpoints.id],
160
+ }),
161
+ }),
162
+ );
163
+
145
164
  export const userRelations = relations(user, ({ many }) => ({
146
165
  sessions: many(session),
147
166
  accounts: many(account),
@@ -0,0 +1,58 @@
1
+ import {
2
+ index,
3
+ integer,
4
+ jsonb,
5
+ pgTable,
6
+ text,
7
+ timestamp,
8
+ uniqueIndex,
9
+ uuid,
10
+ } from "drizzle-orm/pg-core";
11
+ import { timestamps } from "./_shared.js";
12
+ import { webhookDeliveryStatusEnum } from "./enums.js";
13
+ import { webhookEndpoints } from "./webhook-endpoints.js";
14
+
15
+ export const webhookDeliveries = pgTable(
16
+ "webhook_deliveries",
17
+ {
18
+ // internal PK ONLY — NOT the Webhook-Id header.
19
+ id: uuid("id").defaultRandom().primaryKey(),
20
+ endpointId: uuid("endpoint_id")
21
+ .notNull()
22
+ .references(() => webhookEndpoints.id, { onDelete: "cascade" }),
23
+ // denormalized, nullable (MT deferred).
24
+ organizationId: text("organization_id"),
25
+ // == Webhook-Id header; ONE per logical event, shared across endpoints +
26
+ // reused across retries.
27
+ webhookId: text("webhook_id").notNull(),
28
+ eventType: text("event_type").notNull(),
29
+ // producer-side dedup (idempotencyKey/stateId/emailSendId/...).
30
+ dedupeKey: text("dedupe_key"),
31
+ // the EXACT signed envelope { id, type, timestamp, data }.
32
+ payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
33
+ status: webhookDeliveryStatusEnum("status").notNull().default("pending"),
34
+ attemptCount: integer("attempt_count").notNull().default(0),
35
+ nextRetryAt: timestamp("next_retry_at", { withTimezone: true }),
36
+ lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }),
37
+ responseStatus: integer("response_status"),
38
+ // truncated to ≤1KB in app.
39
+ responseBodySnippet: text("response_body_snippet"),
40
+ deliveredAt: timestamp("delivered_at", { withTimezone: true }),
41
+ lastError: text("last_error"),
42
+ ...timestamps,
43
+ },
44
+ (table) => [
45
+ index("webhook_deliveries_endpoint_idx").on(table.endpointId),
46
+ // reaper sweep: due-pending + stale-sending recovery.
47
+ index("webhook_deliveries_status_next_retry_idx").on(
48
+ table.status,
49
+ table.nextRetryAt,
50
+ ),
51
+ // producer-side fan-out idempotency. PARTIAL-effective: Postgres treats
52
+ // multiple NULL dedupeKey as distinct, so undeduped events are never blocked.
53
+ uniqueIndex("webhook_deliveries_endpoint_dedupe_idx").on(
54
+ table.endpointId,
55
+ table.dedupeKey,
56
+ ),
57
+ ],
58
+ );
@@ -0,0 +1,55 @@
1
+ import {
2
+ boolean,
3
+ index,
4
+ jsonb,
5
+ pgTable,
6
+ text,
7
+ timestamp,
8
+ uuid,
9
+ } from "drizzle-orm/pg-core";
10
+ import { timestamps } from "./_shared.js";
11
+
12
+ // Locally declared to avoid an engine→db dependency cycle: the engine's
13
+ // `webhook-signing.ts` owns the authoritative `WEBHOOK_EVENT_TYPES` tuple +
14
+ // `WebhookEventType` union. This schema keeps a structural string alias so the
15
+ // jsonb column is typed without importing the engine.
16
+ export type WebhookEventType = string;
17
+
18
+ export const webhookEndpoints = pgTable(
19
+ "webhook_endpoints",
20
+ {
21
+ id: uuid("id").defaultRandom().primaryKey(),
22
+ organizationId: text("organization_id"),
23
+ url: text("url").notNull(),
24
+ description: text("description"),
25
+ // The delivery adapter selector. "webhook" (the default) is the signed
26
+ // Standard-Webhooks POST that existing subscribers receive — byte-identical
27
+ // to before this column existed. Any other value (e.g. "posthog") selects a
28
+ // delivery-time TRANSFORM adapter that reuses the same durable delivery
29
+ // machinery but rewrites url/headers/body for a vendor destination.
30
+ kind: text("kind").notNull().default("webhook"),
31
+ // Per-destination configuration for keyed adapters (e.g. PostHog's
32
+ // `{ apiKey, host }`). Null for `kind="webhook"` (it reads `secret` instead).
33
+ // Keyed destinations keep their credentials HERE, not in a fake `whsec_`.
34
+ config: jsonb("config").$type<Record<string, unknown>>(),
35
+ // whsec_<base64url> PLAINTEXT (recoverable; re-signed every delivery).
36
+ // Nullable: only `kind="webhook"` carries a signing secret; keyed
37
+ // destinations authenticate via `config` and the webhook adapter is the only
38
+ // reader of this column.
39
+ secret: text("secret"),
40
+ // e.g. "whsec_AbCd" — safe to show on list/get. Nullable alongside `secret`.
41
+ secretPrefix: text("secret_prefix"),
42
+ eventTypes: jsonb("event_types")
43
+ .$type<WebhookEventType[]>()
44
+ .notNull()
45
+ .default([]),
46
+ disabled: boolean("disabled").notNull().default(false),
47
+ // written by the delivery task on a successful (2xx) delivery.
48
+ lastDeliveryAt: timestamp("last_delivery_at", { withTimezone: true }),
49
+ ...timestamps,
50
+ },
51
+ (table) => [
52
+ index("webhook_endpoints_org_idx").on(table.organizationId),
53
+ index("webhook_endpoints_disabled_idx").on(table.disabled),
54
+ ],
55
+ );