@hogsend/db 0.0.1 → 0.2.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/0009_productive_shadowcat.sql +2 -0
- package/drizzle/0010_equal_legion.sql +8 -0
- package/drizzle/0011_robust_dracula.sql +35 -0
- package/drizzle/0012_fixed_sebastian_shaw.sql +2 -0
- package/drizzle/meta/0009_snapshot.json +2482 -0
- package/drizzle/meta/0010_snapshot.json +2521 -0
- package/drizzle/meta/0011_snapshot.json +2804 -0
- package/drizzle/meta/0012_snapshot.json +2825 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +2 -1
- package/src/demo-seed.ts +763 -0
- package/src/schema/bucket-configs.ts +17 -0
- package/src/schema/bucket-memberships.ts +72 -0
- package/src/schema/contacts.ts +7 -0
- package/src/schema/email-sends.ts +15 -0
- package/src/schema/enums.ts +5 -0
- package/src/schema/index.ts +2 -0
- package/src/schema/relations.ts +15 -0
- package/src/stamp.ts +85 -0
|
@@ -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
|
+
);
|
package/src/schema/contacts.ts
CHANGED
|
@@ -15,6 +15,13 @@ export const contacts = pgTable(
|
|
|
15
15
|
organizationId: text("organization_id"),
|
|
16
16
|
externalId: text("external_id").notNull().unique(),
|
|
17
17
|
email: text("email"),
|
|
18
|
+
/**
|
|
19
|
+
* Opportunistic IANA-timezone cache (e.g. "America/New_York"). Populated
|
|
20
|
+
* best-effort when a tz is resolved from PostHog person props. PostHog and
|
|
21
|
+
* `properties` jsonb remain authoritative sources — this column sits below
|
|
22
|
+
* them in the resolution precedence, so nothing is blocked on it.
|
|
23
|
+
*/
|
|
24
|
+
timezone: text("timezone"),
|
|
18
25
|
properties: jsonb("properties")
|
|
19
26
|
.$type<Record<string, unknown>>()
|
|
20
27
|
.default({}),
|
|
@@ -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,5 +38,12 @@ 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),
|
|
42
|
+
// Serves the frequency-cap COUNT (recipient + recency, optionally category).
|
|
43
|
+
index("email_sends_freq_cap_idx").on(
|
|
44
|
+
table.toEmail,
|
|
45
|
+
table.createdAt,
|
|
46
|
+
table.category,
|
|
47
|
+
),
|
|
33
48
|
],
|
|
34
49
|
);
|
package/src/schema/enums.ts
CHANGED
package/src/schema/index.ts
CHANGED
|
@@ -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";
|
package/src/schema/relations.ts
CHANGED
|
@@ -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
|
+
}
|