@hogsend/db 0.6.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.
@@ -99,6 +99,41 @@
99
99
  "when": 1780734148043,
100
100
  "tag": "0013_illegal_firedrake",
101
101
  "breakpoints": true
102
+ },
103
+ {
104
+ "idx": 14,
105
+ "version": "7",
106
+ "when": 1780829225830,
107
+ "tag": "0014_front_door_identity",
108
+ "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "7",
113
+ "when": 1780833852463,
114
+ "tag": "0015_crazy_kronos",
115
+ "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1780840132281,
121
+ "tag": "0016_campaigns",
122
+ "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "7",
127
+ "when": 1780841637175,
128
+ "tag": "0017_long_spacker_dave",
129
+ "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "7",
134
+ "when": 1780855021994,
135
+ "tag": "0018_webhooks",
136
+ "breakpoints": true
102
137
  }
103
138
  ]
104
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/db",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,71 @@
1
+ import { sql } from "drizzle-orm";
2
+ import {
3
+ index,
4
+ integer,
5
+ jsonb,
6
+ pgTable,
7
+ text,
8
+ timestamp,
9
+ uniqueIndex,
10
+ uuid,
11
+ } from "drizzle-orm/pg-core";
12
+ import { timestamps } from "./_shared.js";
13
+
14
+ /**
15
+ * One-shot campaign / broadcast (Loops "campaign" parity): a single email
16
+ * template sent to every subscribed member of a LIST (or every active member of
17
+ * a BUCKET). A row is created in `queued` by `POST /v1/campaigns`, then the
18
+ * durable `send-campaign` Hatchet task transitions it `sending → sent`/`failed`
19
+ * and tallies the final counts.
20
+ *
21
+ * `status` is a plain text column (not an enum) so adding a future state needs
22
+ * no migration; the app constrains it to `queued|sending|sent|failed`.
23
+ *
24
+ * `audienceKind` + `audienceId` reference a code-defined list (ListRegistry) or
25
+ * bucket (BucketRegistry) by string id — NOT a contacts FK, so there is no
26
+ * relation to wire.
27
+ */
28
+ export const campaigns = pgTable(
29
+ "campaigns",
30
+ {
31
+ id: uuid("id").defaultRandom().primaryKey(),
32
+ organizationId: text("organization_id"),
33
+ name: text("name").notNull(),
34
+ // queued | sending | sent | failed
35
+ status: text("status").notNull().default("queued"),
36
+ // "list" | "bucket"
37
+ audienceKind: text("audience_kind").notNull(),
38
+ // the list id (ListRegistry) or bucket id (BucketRegistry)
39
+ audienceId: text("audience_id").notNull(),
40
+ templateKey: text("template_key").notNull(),
41
+ props: jsonb("props").$type<Record<string, unknown>>().default({}),
42
+ fromEmail: text("from_email"),
43
+ subject: text("subject"),
44
+ /**
45
+ * Optional client-supplied idempotency key (POST /v1/campaigns
46
+ * `Idempotency-Key` header / body field). A retried create with the same key
47
+ * resolves to the EXISTING campaign instead of spawning a second broadcast
48
+ * (a distinct campaignId would give the same recipient a different per-send
49
+ * idempotency key, double-sending the blast). Uniqueness is enforced by the
50
+ * partial-unique index below (NULL keys are unconstrained).
51
+ */
52
+ idempotencyKey: text("idempotency_key"),
53
+ totalRecipients: integer("total_recipients").notNull().default(0),
54
+ sentCount: integer("sent_count").notNull().default(0),
55
+ skippedCount: integer("skipped_count").notNull().default(0),
56
+ failedCount: integer("failed_count").notNull().default(0),
57
+ startedAt: timestamp("started_at", { withTimezone: true }),
58
+ completedAt: timestamp("completed_at", { withTimezone: true }),
59
+ ...timestamps,
60
+ },
61
+ (table) => [
62
+ index("campaigns_status_idx").on(table.status),
63
+ index("campaigns_created_at_idx").on(table.createdAt),
64
+ // Partial-unique on the client idempotency key (scoped to non-NULL keys so
65
+ // the common keyless create is unconstrained). A retried create with the
66
+ // same key collides here and resolves to the existing campaign.
67
+ uniqueIndex("campaigns_idempotency_key_idx")
68
+ .on(table.idempotencyKey)
69
+ .where(sql`idempotency_key IS NOT NULL`),
70
+ ],
71
+ );
@@ -0,0 +1,41 @@
1
+ import { index, pgTable, text, uniqueIndex, uuid } from "drizzle-orm/pg-core";
2
+ import { timestamps } from "./_shared.js";
3
+ import { contacts } from "./contacts.js";
4
+
5
+ /**
6
+ * Alias table for identity resolution. After a merge (or a fill-in-link
7
+ * "promote"), a stale identity key — the loser's old external_id / email /
8
+ * anonymous_id — sits on a soft-deleted row, so the `deleted_at IS NULL`
9
+ * lookups in `findByExternalId`/`findByEmail` miss it. This table lets a stale
10
+ * key resolve to the SURVIVOR contact instead of minting a fresh row and
11
+ * re-splitting history (risk 5). Each `findByX` falls back to it on a miss.
12
+ */
13
+ export const contactAliases = pgTable(
14
+ "contact_aliases",
15
+ {
16
+ id: uuid("id").defaultRandom().primaryKey(),
17
+ // The SURVIVOR a stale key resolves TO.
18
+ contactId: uuid("contact_id")
19
+ .notNull()
20
+ .references(() => contacts.id, { onDelete: "cascade" }),
21
+ // 'email' | 'external' | 'anonymous'
22
+ aliasKind: text("alias_kind").notNull(),
23
+ // The stale key value (the loser's old external_id / normalized email /
24
+ // anonymous_id).
25
+ aliasValue: text("alias_value").notNull(),
26
+ // Provenance: the loser contact id this alias came from (nullable — a
27
+ // 'promote' alias may have no distinct loser row).
28
+ fromContactId: uuid("from_contact_id"),
29
+ // 'merge' | 'promote'
30
+ reason: text("reason").notNull(),
31
+ ...timestamps,
32
+ },
33
+ (table) => [
34
+ // One alias per (kind, value): a stale key resolves to exactly one survivor.
35
+ uniqueIndex("contact_aliases_kind_value_idx").on(
36
+ table.aliasKind,
37
+ table.aliasValue,
38
+ ),
39
+ index("contact_aliases_contact_id_idx").on(table.contactId),
40
+ ],
41
+ );
@@ -1,9 +1,11 @@
1
+ import { sql } from "drizzle-orm";
1
2
  import {
2
3
  index,
3
4
  jsonb,
4
5
  pgTable,
5
6
  text,
6
7
  timestamp,
8
+ uniqueIndex,
7
9
  uuid,
8
10
  } from "drizzle-orm/pg-core";
9
11
  import { timestamps } from "./_shared.js";
@@ -13,8 +15,22 @@ export const contacts = pgTable(
13
15
  {
14
16
  id: uuid("id").defaultRandom().primaryKey(),
15
17
  organizationId: text("organization_id"),
16
- externalId: text("external_id").notNull().unique(),
18
+ /**
19
+ * Stable external/distinct id (= the `user_id` text key joined by every
20
+ * contact-referencing table). NULLABLE since D1: contacts can be email-only
21
+ * or anonymous-only. Uniqueness is enforced by the partial-unique index
22
+ * below (scoped to live, non-deleted rows) rather than an inline `.unique()`
23
+ * — a soft-deleted loser row must be able to keep its stale external_id
24
+ * until a merge re-points it.
25
+ */
26
+ externalId: text("external_id"),
17
27
  email: text("email"),
28
+ /**
29
+ * Stable anonymous/distinct id for the future anonymous→identified path.
30
+ * NULLABLE. Like external_id, uniqueness is enforced by a partial-unique
31
+ * index scoped to live, non-deleted rows.
32
+ */
33
+ anonymousId: text("anonymous_id"),
18
34
  /**
19
35
  * Opportunistic IANA-timezone cache (e.g. "America/New_York"). Populated
20
36
  * best-effort when a tz is resolved from PostHog person props. PostHog and
@@ -34,5 +50,24 @@ export const contacts = pgTable(
34
50
  deletedAt: timestamp("deleted_at", { withTimezone: true }),
35
51
  ...timestamps,
36
52
  },
37
- (table) => [index("contacts_email_idx").on(table.email)],
53
+ (table) => [
54
+ // Plain (non-unique) lookup index on email — kept for the email-search path.
55
+ index("contacts_email_idx").on(table.email),
56
+ // D1 partial-unique identity indexes. Each is scoped to live rows
57
+ // (`WHERE col IS NOT NULL AND deleted_at IS NULL`) so a soft-deleted loser
58
+ // row can retain its stale key until a merge re-points it (merge soft-
59
+ // deletes the loser FIRST, then copies keys onto the survivor — risk 4).
60
+ uniqueIndex("contacts_external_id_unique_idx")
61
+ .on(table.externalId)
62
+ .where(sql`external_id IS NOT NULL AND deleted_at IS NULL`),
63
+ // Functional partial-unique on lower(email) — email is a case-insensitive
64
+ // resolvable identity key. Emails are stored already-normalized (trim +
65
+ // toLowerCase), so lower() here is belt-and-suspenders.
66
+ uniqueIndex("contacts_email_unique_idx")
67
+ .on(sql`lower(email)`)
68
+ .where(sql`email IS NOT NULL AND deleted_at IS NULL`),
69
+ uniqueIndex("contacts_anonymous_id_unique_idx")
70
+ .on(table.anonymousId)
71
+ .where(sql`anonymous_id IS NOT NULL AND deleted_at IS NULL`),
72
+ ],
38
73
  );
@@ -1,4 +1,11 @@
1
- import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
1
+ import {
2
+ index,
3
+ pgTable,
4
+ text,
5
+ timestamp,
6
+ uniqueIndex,
7
+ uuid,
8
+ } from "drizzle-orm/pg-core";
2
9
  import { timestamps } from "./_shared.js";
3
10
  import { emailSendStatusEnum } from "./enums.js";
4
11
  import { journeyStates } from "./journey-states.js";
@@ -30,6 +37,11 @@ export const emailSends = pgTable(
30
37
  // Bounce classification from the Resend webhook (hard/soft/transient + reason).
31
38
  bounceType: text("bounce_type"),
32
39
  bounceReason: text("bounce_reason"),
40
+ // Caller-supplied idempotency key (POST /v1/emails). A retry with the same
41
+ // key short-circuits to the prior send instead of dispatching a duplicate —
42
+ // mirrors the user_events idempotency pattern. Nullable: journey/system sends
43
+ // don't set it.
44
+ idempotencyKey: text("idempotency_key"),
33
45
  ...timestamps,
34
46
  },
35
47
  (table) => [
@@ -45,5 +57,8 @@ export const emailSends = pgTable(
45
57
  table.createdAt,
46
58
  table.category,
47
59
  ),
60
+ // Idempotency dedup for POST /v1/emails (NULLs are distinct in Postgres, so
61
+ // unkeyed journey/system sends never collide).
62
+ uniqueIndex("email_sends_idempotency_key_idx").on(table.idempotencyKey),
48
63
  ],
49
64
  );
@@ -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
+ ]);
@@ -5,6 +5,8 @@ export * from "./audit-logs.js";
5
5
  export * from "./auth.js";
6
6
  export * from "./bucket-configs.js";
7
7
  export * from "./bucket-memberships.js";
8
+ export * from "./campaigns.js";
9
+ export * from "./contact-aliases.js";
8
10
  export * from "./contacts.js";
9
11
  export * from "./dead-letter-queue.js";
10
12
  export * from "./email-preferences.js";
@@ -18,3 +20,5 @@ export * from "./link-clicks.js";
18
20
  export * from "./relations.js";
19
21
  export * from "./tracked-links.js";
20
22
  export * from "./user-events.js";
23
+ export * from "./webhook-deliveries.js";
24
+ export * from "./webhook-endpoints.js";
@@ -13,6 +13,7 @@ import {
13
13
  } from "./auth.js";
14
14
  import { bucketConfigs } from "./bucket-configs.js";
15
15
  import { bucketMemberships } from "./bucket-memberships.js";
16
+ import { contactAliases } from "./contact-aliases.js";
16
17
  import { contacts } from "./contacts.js";
17
18
  import { deadLetterQueue } from "./dead-letter-queue.js";
18
19
  import { emailPreferences } from "./email-preferences.js";
@@ -24,6 +25,8 @@ import { journeyStates } from "./journey-states.js";
24
25
  import { linkClicks } from "./link-clicks.js";
25
26
  import { trackedLinks } from "./tracked-links.js";
26
27
  import { userEvents } from "./user-events.js";
28
+ import { webhookDeliveries } from "./webhook-deliveries.js";
29
+ import { webhookEndpoints } from "./webhook-endpoints.js";
27
30
 
28
31
  export const alertRulesRelations = relations(alertRules, ({ many }) => ({
29
32
  history: many(alertHistory),
@@ -49,10 +52,24 @@ export const journeyConfigsRelations = relations(journeyConfigs, () => ({}));
49
52
  export const bucketConfigsRelations = relations(bucketConfigs, () => ({}));
50
53
 
51
54
  export const contactsRelations = relations(contacts, ({ many }) => ({
55
+ // NOTE: the logical joins below (emailPreferences/userEvents/journeyStates/
56
+ // bucketMemberships) reference contacts.externalId — anonymous-only contacts
57
+ // (external_id NULL) won't resolve through Drizzle relational queries until
58
+ // identified. Acceptable (anon contacts have no prefs/journeys yet); see
59
+ // risk 22. contacts.id is NOT the relational key for those tables.
52
60
  emailPreferences: many(emailPreferences),
53
61
  userEvents: many(userEvents),
54
62
  journeyStates: many(journeyStates),
55
63
  bucketMemberships: many(bucketMemberships),
64
+ // contact_aliases joins on the contacts.id UUID (real FK).
65
+ aliases: many(contactAliases),
66
+ }));
67
+
68
+ export const contactAliasesRelations = relations(contactAliases, ({ one }) => ({
69
+ contact: one(contacts, {
70
+ fields: [contactAliases.contactId],
71
+ references: [contacts.id],
72
+ }),
56
73
  }));
57
74
 
58
75
  export const bucketMembershipsRelations = relations(
@@ -127,6 +144,23 @@ export const linkClicksRelations = relations(linkClicks, ({ one }) => ({
127
144
  }),
128
145
  }));
129
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
+
130
164
  export const userRelations = relations(user, ({ many }) => ({
131
165
  sessions: many(session),
132
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
+ );