@hogsend/db 0.1.0 → 0.3.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.
@@ -0,0 +1,17 @@
1
+ import { boolean, pgTable, text, uniqueIndex, uuid } from "drizzle-orm/pg-core";
2
+ import { timestamps } from "./_shared.js";
3
+
4
+ export const bucketConfigs = pgTable(
5
+ "bucket_configs",
6
+ {
7
+ id: uuid("id").defaultRandom().primaryKey(),
8
+ bucketId: text("bucket_id").notNull(),
9
+ enabled: boolean("enabled").notNull().default(true),
10
+ // Stable hash of the normalized ConditionEval, written at boot. Diffed on the
11
+ // next boot to detect a CRITERIA CHANGE and enqueue the re-evaluation job
12
+ // (Section 6.6 B). Nullable until the first registration.
13
+ criteriaHash: text("criteria_hash"),
14
+ ...timestamps,
15
+ },
16
+ (table) => [uniqueIndex("bucket_configs_bucket_id_idx").on(table.bucketId)],
17
+ );
@@ -0,0 +1,72 @@
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
+ import { bucketMembershipStatusEnum } from "./enums.js";
14
+
15
+ export const bucketMemberships = pgTable(
16
+ "bucket_memberships",
17
+ {
18
+ id: uuid("id").defaultRandom().primaryKey(),
19
+ // multi-tenant insurance (nullable today, NOT in the unique key — see note)
20
+ organizationId: text("organization_id"),
21
+ // logical join to contacts.externalId — NO FK (matches userEvents /
22
+ // journeyStates; membership rows can predate a contacts row).
23
+ userId: text("user_id").notNull(),
24
+ userEmail: text("user_email"), // denormalized so emitted events carry it
25
+ bucketId: text("bucket_id").notNull(),
26
+ status: bucketMembershipStatusEnum("status").notNull().default("active"),
27
+ enteredAt: timestamp("entered_at", { withTimezone: true })
28
+ .defaultNow()
29
+ .notNull(),
30
+ leftAt: timestamp("left_at", { withTimezone: true }),
31
+ // membership epoch / armed deadline for time-based + fastExpiry buckets
32
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
33
+ // unconditional membership TTL deadline (enteredAt + meta.maxDwell). Set once
34
+ // on join, never mutated. The reconcile cron force-leaves rows past it
35
+ // REGARDLESS of criteria — kept separate from expiresAt, which is the
36
+ // criteria-window / minDwell-defer arming epoch (overloaded meaning).
37
+ maxDwellAt: timestamp("max_dwell_at", { withTimezone: true }),
38
+ lastEvaluatedAt: timestamp("last_evaluated_at", { withTimezone: true }),
39
+ entryCount: integer("entry_count").notNull().default(1),
40
+ source: text("source"), // "event" | "reconcile" | "backfill" | "manual"
41
+ context: jsonb("context").$type<Record<string, unknown>>().default({}),
42
+ deletedAt: timestamp("deleted_at", { withTimezone: true }),
43
+ ...timestamps,
44
+ },
45
+ (table) => [
46
+ // EXACTLY ONE ACTIVE membership per (user, bucket), with any number of
47
+ // historical "left" rows coexisting. This is a PARTIAL unique index scoped
48
+ // to active, non-deleted rows — NOT a plain (userId,bucketId,status) unique
49
+ // index. Buckets are re-entrant: a user oscillates join → leave → join →
50
+ // leave forever, so a plain unique key on (userId,bucketId,status) would
51
+ // throw on the SECOND "left" row (two rows share (user,bucket,'left')). The
52
+ // journeyStates model does NOT transfer here because journey terminal states
53
+ // are reached once; bucket "left" is reached repeatedly. The generated SQL
54
+ // is `CREATE UNIQUE INDEX uq_user_bucket_active ON bucket_memberships
55
+ // (user_id, bucket_id) WHERE status = 'active' AND deleted_at IS NULL`.
56
+ // organizationId deliberately OMITTED — same NULLS-DISTINCT caveat as
57
+ // uq_user_journey_active (journey-states.ts:34-40). Add it to the predicate
58
+ // only when multi-tenancy lands and the column is non-null.
59
+ uniqueIndex("uq_user_bucket_active")
60
+ .on(table.userId, table.bucketId)
61
+ .where(sql`status = 'active' AND deleted_at IS NULL`),
62
+ index("bucket_memberships_bucket_id_status_idx").on(
63
+ table.bucketId,
64
+ table.status,
65
+ ), // list members / size metrics
66
+ index("bucket_memberships_user_id_idx").on(table.userId), // a user's buckets
67
+ index("bucket_memberships_last_evaluated_idx").on(table.lastEvaluatedAt),
68
+ index("bucket_memberships_expires_at_idx").on(table.expiresAt),
69
+ // the cron TTL sweep: active rows past their max_dwell_at
70
+ index("bucket_memberships_max_dwell_at_idx").on(table.maxDwellAt),
71
+ ],
72
+ );
@@ -9,6 +9,11 @@ export const emailSends = pgTable(
9
9
  id: uuid("id").defaultRandom().primaryKey(),
10
10
  organizationId: text("organization_id"),
11
11
  journeyStateId: uuid("journey_state_id").references(() => journeyStates.id),
12
+ // Denormalized recipient identity, set at send time. Lets reporting attribute
13
+ // a send to a contact without joining journey_states, and captures journeyless
14
+ // (raw/batch) sends that have no journey linkage. Both nullable.
15
+ userId: text("user_id"),
16
+ userEmail: text("user_email"),
12
17
  templateKey: text("template_key"),
13
18
  resendId: text("resend_id"),
14
19
  fromEmail: text("from_email").notNull(),
@@ -22,6 +27,9 @@ export const emailSends = pgTable(
22
27
  clickedAt: timestamp("clicked_at", { withTimezone: true }),
23
28
  bouncedAt: timestamp("bounced_at", { withTimezone: true }),
24
29
  complainedAt: timestamp("complained_at", { withTimezone: true }),
30
+ // Bounce classification from the Resend webhook (hard/soft/transient + reason).
31
+ bounceType: text("bounce_type"),
32
+ bounceReason: text("bounce_reason"),
25
33
  ...timestamps,
26
34
  },
27
35
  (table) => [
@@ -30,6 +38,7 @@ export const emailSends = pgTable(
30
38
  index("email_sends_status_idx").on(table.status),
31
39
  index("email_sends_created_at_idx").on(table.createdAt),
32
40
  index("email_sends_journey_state_id_idx").on(table.journeyStateId),
41
+ index("email_sends_user_id_idx").on(table.userId),
33
42
  // Serves the frequency-cap COUNT (recipient + recency, optionally category).
34
43
  index("email_sends_freq_cap_idx").on(
35
44
  table.toEmail,
@@ -45,3 +45,8 @@ export const dlqStatusEnum = pgEnum("dlq_status", [
45
45
  "retried",
46
46
  "discarded",
47
47
  ]);
48
+
49
+ export const bucketMembershipStatusEnum = pgEnum("bucket_membership_status", [
50
+ "active",
51
+ "left",
52
+ ]);
@@ -3,6 +3,8 @@ export * from "./alert-rules.js";
3
3
  export * from "./api-keys.js";
4
4
  export * from "./audit-logs.js";
5
5
  export * from "./auth.js";
6
+ export * from "./bucket-configs.js";
7
+ export * from "./bucket-memberships.js";
6
8
  export * from "./contacts.js";
7
9
  export * from "./dead-letter-queue.js";
8
10
  export * from "./email-preferences.js";
@@ -11,6 +11,8 @@ import {
11
11
  session,
12
12
  user,
13
13
  } from "./auth.js";
14
+ import { bucketConfigs } from "./bucket-configs.js";
15
+ import { bucketMemberships } from "./bucket-memberships.js";
14
16
  import { contacts } from "./contacts.js";
15
17
  import { deadLetterQueue } from "./dead-letter-queue.js";
16
18
  import { emailPreferences } from "./email-preferences.js";
@@ -44,12 +46,25 @@ export const importJobsRelations = relations(importJobs, () => ({}));
44
46
 
45
47
  export const journeyConfigsRelations = relations(journeyConfigs, () => ({}));
46
48
 
49
+ export const bucketConfigsRelations = relations(bucketConfigs, () => ({}));
50
+
47
51
  export const contactsRelations = relations(contacts, ({ many }) => ({
48
52
  emailPreferences: many(emailPreferences),
49
53
  userEvents: many(userEvents),
50
54
  journeyStates: many(journeyStates),
55
+ bucketMemberships: many(bucketMemberships),
51
56
  }));
52
57
 
58
+ export const bucketMembershipsRelations = relations(
59
+ bucketMemberships,
60
+ ({ one }) => ({
61
+ contact: one(contacts, {
62
+ fields: [bucketMemberships.userId],
63
+ references: [contacts.externalId],
64
+ }),
65
+ }),
66
+ );
67
+
53
68
  export const emailPreferencesRelations = relations(
54
69
  emailPreferences,
55
70
  ({ one }) => ({
package/src/stamp.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { existsSync } from "node:fs";
2
+ import { pathToFileURL } from "node:url";
3
+ import { sql } from "drizzle-orm";
4
+ import { readMigrationFiles } from "drizzle-orm/migrator";
5
+ import { drizzle } from "drizzle-orm/postgres-js";
6
+ import postgres from "postgres";
7
+ import {
8
+ ENGINE_MIGRATIONS_SCHEMA,
9
+ ENGINE_MIGRATIONS_TABLE,
10
+ } from "./version.js";
11
+
12
+ // `db:stamp` — record every bundled engine migration as applied in the ledger
13
+ // WITHOUT running any SQL.
14
+ //
15
+ // Use this when the database schema is already current but the migration ledger
16
+ // is behind — the classic case being a dev DB bootstrapped with `db:push`
17
+ // (which syncs the schema directly and never writes the ledger). In that state
18
+ // `db:migrate` tries to replay migrations whose objects already exist ("type
19
+ // already exists") and the boot guard reports `migration_pending`.
20
+ //
21
+ // WARNING: only run this when you are sure the schema already matches HEAD
22
+ // (e.g. it was `db:push`-ed). Stamping a DB that is genuinely missing columns
23
+ // would mark migrations applied without creating their objects.
24
+
25
+ async function run(): Promise<void> {
26
+ const databaseUrl = process.env.DATABASE_URL;
27
+ if (!databaseUrl) {
28
+ console.error("DATABASE_URL environment variable is required");
29
+ process.exit(1);
30
+ }
31
+
32
+ const migrationsFolder = new URL("../drizzle", import.meta.url).pathname;
33
+ if (!existsSync(migrationsFolder)) {
34
+ console.log("[stamp] No migrations folder found, nothing to stamp.");
35
+ return;
36
+ }
37
+
38
+ const migrations = readMigrationFiles({ migrationsFolder });
39
+ const client = postgres(databaseUrl, { max: 1, idle_timeout: 0 });
40
+ const db = drizzle(client);
41
+
42
+ const S = sql.identifier(ENGINE_MIGRATIONS_SCHEMA);
43
+ const T = sql.identifier(ENGINE_MIGRATIONS_TABLE);
44
+
45
+ try {
46
+ await db.execute(sql`CREATE SCHEMA IF NOT EXISTS ${S}`);
47
+ await db.execute(
48
+ sql`CREATE TABLE IF NOT EXISTS ${S}.${T} ("id" SERIAL PRIMARY KEY, "hash" text NOT NULL, "created_at" bigint)`,
49
+ );
50
+
51
+ const existing = (await db.execute(
52
+ sql`SELECT created_at FROM ${S}.${T}`,
53
+ )) as unknown as Array<{ created_at: number | string | null }>;
54
+ const have = new Set(existing.map((r) => String(r.created_at)));
55
+
56
+ let inserted = 0;
57
+ for (const m of migrations) {
58
+ if (!have.has(String(m.folderMillis))) {
59
+ await db.execute(
60
+ sql`INSERT INTO ${S}.${T} ("hash", "created_at") VALUES (${m.hash}, ${m.folderMillis})`,
61
+ );
62
+ inserted++;
63
+ }
64
+ }
65
+
66
+ console.log(
67
+ `[stamp] Ledger now reflects ${migrations.length} bundled migration(s); inserted ${inserted} missing row(s). No schema changes were applied.`,
68
+ );
69
+ } finally {
70
+ await client.end();
71
+ }
72
+ }
73
+
74
+ const invokedDirectly =
75
+ process.argv[1] !== undefined &&
76
+ import.meta.url === pathToFileURL(process.argv[1]).href;
77
+
78
+ if (invokedDirectly) {
79
+ run()
80
+ .then(() => process.exit(0))
81
+ .catch((err) => {
82
+ console.error("Stamp failed:", err);
83
+ process.exit(1);
84
+ });
85
+ }