@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.
- package/drizzle/0014_front_door_identity.sql +20 -0
- package/drizzle/0015_crazy_kronos.sql +2 -0
- package/drizzle/0016_campaigns.sql +23 -0
- package/drizzle/0017_long_spacker_dave.sql +2 -0
- package/drizzle/meta/0014_snapshot.json +3080 -0
- package/drizzle/meta/0015_snapshot.json +3101 -0
- package/drizzle/meta/0016_snapshot.json +3262 -0
- package/drizzle/meta/0017_snapshot.json +3284 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +1 -1
- package/src/schema/campaigns.ts +71 -0
- package/src/schema/contact-aliases.ts +41 -0
- package/src/schema/contacts.ts +37 -2
- package/src/schema/email-sends.ts +16 -1
- package/src/schema/index.ts +2 -0
- package/src/schema/relations.ts +15 -0
|
@@ -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
|
@@ -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
|
+
);
|
package/src/schema/contacts.ts
CHANGED
|
@@ -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
|
-
|
|
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) => [
|
|
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 {
|
|
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
|
);
|
package/src/schema/index.ts
CHANGED
|
@@ -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";
|
package/src/schema/relations.ts
CHANGED
|
@@ -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(
|