@hogsend/db 0.6.0 → 0.7.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,34 @@
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
102
130
  }
103
131
  ]
104
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/db",
3
- "version": "0.6.0",
3
+ "version": "0.7.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
  );
@@ -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";
@@ -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";
@@ -49,10 +50,24 @@ export const journeyConfigsRelations = relations(journeyConfigs, () => ({}));
49
50
  export const bucketConfigsRelations = relations(bucketConfigs, () => ({}));
50
51
 
51
52
  export const contactsRelations = relations(contacts, ({ many }) => ({
53
+ // NOTE: the logical joins below (emailPreferences/userEvents/journeyStates/
54
+ // bucketMemberships) reference contacts.externalId — anonymous-only contacts
55
+ // (external_id NULL) won't resolve through Drizzle relational queries until
56
+ // identified. Acceptable (anon contacts have no prefs/journeys yet); see
57
+ // risk 22. contacts.id is NOT the relational key for those tables.
52
58
  emailPreferences: many(emailPreferences),
53
59
  userEvents: many(userEvents),
54
60
  journeyStates: many(journeyStates),
55
61
  bucketMemberships: many(bucketMemberships),
62
+ // contact_aliases joins on the contacts.id UUID (real FK).
63
+ aliases: many(contactAliases),
64
+ }));
65
+
66
+ export const contactAliasesRelations = relations(contactAliases, ({ one }) => ({
67
+ contact: one(contacts, {
68
+ fields: [contactAliases.contactId],
69
+ references: [contacts.id],
70
+ }),
56
71
  }));
57
72
 
58
73
  export const bucketMembershipsRelations = relations(