@hogsend/db 0.7.0 → 0.8.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,13 @@
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
130
137
  }
131
138
  ]
132
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/db",
3
- "version": "0.7.0",
3
+ "version": "0.8.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,42 @@
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
+ // whsec_<base64url> PLAINTEXT (recoverable; re-signed every delivery).
26
+ secret: text("secret").notNull(),
27
+ // e.g. "whsec_AbCd" — safe to show on list/get.
28
+ secretPrefix: text("secret_prefix").notNull(),
29
+ eventTypes: jsonb("event_types")
30
+ .$type<WebhookEventType[]>()
31
+ .notNull()
32
+ .default([]),
33
+ disabled: boolean("disabled").notNull().default(false),
34
+ // written by the delivery task on a successful (2xx) delivery.
35
+ lastDeliveryAt: timestamp("last_delivery_at", { withTimezone: true }),
36
+ ...timestamps,
37
+ },
38
+ (table) => [
39
+ index("webhook_endpoints_org_idx").on(table.organizationId),
40
+ index("webhook_endpoints_disabled_idx").on(table.disabled),
41
+ ],
42
+ );