@hogsend/cli 0.1.0 → 0.2.1

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.
Files changed (45) hide show
  1. package/dist/bin.js +575 -104
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +4 -1
  4. package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
  5. package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
  6. package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
  7. package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
  8. package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
  9. package/skills/hogsend-authoring-emails/SKILL.md +68 -0
  10. package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
  11. package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
  12. package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
  13. package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
  14. package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
  15. package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +117 -0
  16. package/skills/hogsend-authoring-journeys/references/journey-context.md +133 -0
  17. package/skills/hogsend-authoring-journeys/references/journey-meta.md +145 -0
  18. package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
  19. package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
  20. package/skills/hogsend-cli/SKILL.md +1 -0
  21. package/skills/hogsend-conditions/SKILL.md +70 -0
  22. package/skills/hogsend-conditions/references/condition-types.md +251 -0
  23. package/skills/hogsend-conditions/references/durations.md +90 -0
  24. package/skills/hogsend-conditions/references/examples.md +188 -0
  25. package/skills/hogsend-database/SKILL.md +70 -0
  26. package/skills/hogsend-database/references/client-track-schema.md +97 -0
  27. package/skills/hogsend-database/references/migrations.md +132 -0
  28. package/skills/hogsend-database/references/schema-drift.md +123 -0
  29. package/skills/hogsend-deploy/SKILL.md +62 -0
  30. package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
  31. package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
  32. package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
  33. package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
  34. package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
  35. package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
  36. package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
  37. package/src/commands/doctor.ts +22 -0
  38. package/src/commands/index.ts +4 -0
  39. package/src/commands/skills.ts +36 -96
  40. package/src/commands/studio.ts +261 -0
  41. package/src/commands/upgrade.ts +245 -0
  42. package/src/lib/skills.ts +186 -0
  43. package/studio/assets/index-BVA9GZqq.css +1 -0
  44. package/studio/assets/index-kPwzOOyG.js +230 -0
  45. package/studio/index.html +13 -0
@@ -0,0 +1,251 @@
1
+ # Condition types — exact shapes & operators
2
+
3
+ The condition system is the `ConditionEval` discriminated union from
4
+ `@hogsend/core` (re-exported by `@hogsend/engine`). Every condition is a plain
5
+ POJO discriminated by `type`. This is the canonical reference for the shapes,
6
+ operators, and how the engine evaluates each.
7
+
8
+ ```ts
9
+ import type {
10
+ ConditionEval,
11
+ PropertyCondition,
12
+ EventCondition,
13
+ EmailEngagementCondition,
14
+ CompositeCondition,
15
+ } from "@hogsend/core/types";
16
+ ```
17
+
18
+ ```ts
19
+ type ConditionEval =
20
+ | PropertyCondition
21
+ | EventCondition
22
+ | EmailEngagementCondition
23
+ | CompositeCondition;
24
+ ```
25
+
26
+ ---
27
+
28
+ ## 1. `property` — `PropertyCondition`
29
+
30
+ Compares a single property value against an expected `value`.
31
+
32
+ ```ts
33
+ interface PropertyCondition {
34
+ type: "property";
35
+ property: string;
36
+ operator:
37
+ | "eq"
38
+ | "neq"
39
+ | "gt"
40
+ | "gte"
41
+ | "lt"
42
+ | "lte"
43
+ | "exists"
44
+ | "not_exists"
45
+ | "contains";
46
+ value?: string | number | boolean;
47
+ }
48
+ ```
49
+
50
+ Operator semantics (from `conditions/property.ts`):
51
+
52
+ | operator | meaning | value type | notes |
53
+ |----------|---------|------------|-------|
54
+ | `eq` | `actual === value` | string \| number \| boolean | strict equality |
55
+ | `neq` | `actual !== value` | string \| number \| boolean | |
56
+ | `gt` / `gte` / `lt` / `lte` | numeric comparison | number | both sides must be `number`, else `false` |
57
+ | `exists` | value is not `undefined`/`null` | omit `value` | |
58
+ | `not_exists` | value is `undefined`/`null` | omit `value` | |
59
+ | `contains` | `actual.includes(value)` | string | both sides must be `string`, else `false` |
60
+
61
+ ```ts
62
+ // Examples (declarative POJOs)
63
+ { type: "property", property: "plan", operator: "eq", value: "pro" }
64
+ { type: "property", property: "seats", operator: "gte", value: 5 }
65
+ { type: "property", property: "company", operator: "exists" }
66
+ { type: "property", property: "email", operator: "contains", value: "@acme.com" }
67
+ ```
68
+
69
+ Used directly by `trigger.where` and `exitOn[].where` (both are
70
+ `PropertyCondition[]`, AND-ed via `evaluatePropertyConditions` — see
71
+ `references/examples.md`).
72
+
73
+ ---
74
+
75
+ ## 2. `event` — `EventCondition`
76
+
77
+ Counts occurrences of `eventName`, optionally inside a rolling `within` window,
78
+ and applies a `check`. This is the only type with a time window.
79
+
80
+ ```ts
81
+ import type { DurationObject } from "@hogsend/core";
82
+
83
+ interface EventCondition {
84
+ type: "event";
85
+ eventName: string;
86
+ check: "exists" | "not_exists" | "count";
87
+ operator?: "gt" | "gte" | "lt" | "lte" | "eq"; // only with check:"count"
88
+ value?: number; // only with check:"count"
89
+ within?: DurationObject; // rolling window, e.g. days(7)
90
+ }
91
+ ```
92
+
93
+ How it evaluates (`conditions/event.ts`): it counts rows in `userEvents` for
94
+ the user matching `eventName`, restricted to `occurredAt >= now - within` when
95
+ `within` is set, then:
96
+
97
+ | `check` | matches when |
98
+ |---------|--------------|
99
+ | `exists` | `count > 0` |
100
+ | `not_exists` | `count === 0` |
101
+ | `count` + `operator`/`value` | `count <op> value` (`gt`/`gte`/`lt`/`lte`/`eq`) |
102
+ | `count` with no `operator`/`value` | falls back to `count > 0` |
103
+
104
+ `within` makes the condition time-based: in a bucket, a windowed event leg is
105
+ what the reconcile cron sweeps. See the hogsend-authoring-buckets skill for the
106
+ reconcile implications.
107
+
108
+ ```ts
109
+ // Declarative
110
+ { type: "event", eventName: "app.active", check: "exists" }
111
+ { type: "event", eventName: "app.active", check: "not_exists", within: days(7) }
112
+ { type: "event", eventName: "purchase", check: "count", operator: "gte", value: 3 }
113
+ ```
114
+
115
+ ---
116
+
117
+ ## 3. `email_engagement` — `EmailEngagementCondition`
118
+
119
+ Checks the open/click state of the MOST RECENT send of a given template to the
120
+ user.
121
+
122
+ ```ts
123
+ interface EmailEngagementCondition {
124
+ type: "email_engagement";
125
+ templateKey: string;
126
+ check: "opened" | "clicked" | "not_opened" | "not_clicked";
127
+ }
128
+ ```
129
+
130
+ How it evaluates (`conditions/email-engagement.ts`): finds the latest
131
+ `emailSends` row for the user + `templateKey`. If there is NO send, every check
132
+ returns `false`. Otherwise:
133
+
134
+ | `check` | matches when |
135
+ |---------|--------------|
136
+ | `opened` | `openedAt` is set |
137
+ | `clicked` | `clickedAt` is set |
138
+ | `not_opened` | `openedAt` is null |
139
+ | `not_clicked` | `clickedAt` is null |
140
+
141
+ `templateKey` is a key from your `src/emails/` template registry (the same
142
+ values you use in `Templates`).
143
+
144
+ ```ts
145
+ { type: "email_engagement", templateKey: "welcome", check: "not_opened" }
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 4. `composite` — `CompositeCondition`
151
+
152
+ AND / OR over child `ConditionEval`s. Children can themselves be composites
153
+ (nest freely).
154
+
155
+ ```ts
156
+ interface CompositeCondition {
157
+ type: "composite";
158
+ operator: "and" | "or";
159
+ conditions: ConditionEval[];
160
+ }
161
+ ```
162
+
163
+ Short-circuit semantics (`conditions/composite.ts`): `and` returns `false` on
164
+ the first child that fails; `or` returns `true` on the first child that passes.
165
+
166
+ ```ts
167
+ {
168
+ type: "composite",
169
+ operator: "and",
170
+ conditions: [
171
+ { type: "property", property: "plan", operator: "eq", value: "trial" },
172
+ { type: "event", eventName: "app.active", check: "not_exists", within: days(7) },
173
+ ],
174
+ }
175
+ ```
176
+
177
+ ---
178
+
179
+ ## The fluent builder (bucket `criteria` only)
180
+
181
+ `defineBucket`'s `criteria` accepts either a `ConditionEval` POJO OR a function
182
+ `(b) => ConditionEval`, where `b` is the `CriteriaBuilder`. The builder runs
183
+ ONCE at definition time and returns the same POJO — it never executes per user.
184
+ You can also import `criteriaBuilder` standalone to compose reusable fragments.
185
+
186
+ ```ts
187
+ import { criteriaBuilder } from "@hogsend/core";
188
+ ```
189
+
190
+ ```ts
191
+ interface CriteriaBuilder {
192
+ prop(property: string): PropertyMatcher;
193
+ event(eventName: string): EventMatcher;
194
+ all(...conditions: ConditionEval[]): CompositeCondition; // composite "and"
195
+ any(...conditions: ConditionEval[]): CompositeCondition; // composite "or"
196
+ }
197
+ ```
198
+
199
+ `PropertyMatcher` terminals → `PropertyCondition`:
200
+
201
+ ```ts
202
+ b.prop("plan").eq("pro") // operator "eq"
203
+ b.prop("plan").neq("free") // "neq"
204
+ b.prop("seats").gt(5) // "gt"
205
+ b.prop("seats").gte(5) // "gte"
206
+ b.prop("seats").lt(10) // "lt"
207
+ b.prop("seats").lte(10) // "lte"
208
+ b.prop("email").contains("@acme") // "contains"
209
+ b.prop("company").exists() // "exists"
210
+ b.prop("company").notExists() // "not_exists"
211
+ ```
212
+
213
+ `EventMatcher` — optional `.within(window)` precedes the terminal; terminals →
214
+ `EventCondition`:
215
+
216
+ ```ts
217
+ b.event("app.active").exists() // check "exists"
218
+ b.event("app.active").within(days(7)).notExists() // check "not_exists" + window
219
+ b.event("purchase").count("gte", 3) // check "count", operator "gte"
220
+ b.event("purchase").atLeast(3) // count gte 3
221
+ b.event("purchase").moreThan(3) // count gt 3
222
+ b.event("purchase").atMost(3) // count lte 3
223
+ b.event("purchase").lessThan(3) // count lt 3
224
+ b.event("purchase").exactly(3) // count eq 3
225
+ ```
226
+
227
+ There is no builder terminal for `email_engagement` — author those as POJOs
228
+ inside `b.all(...)` / `b.any(...)` when you need them.
229
+
230
+ ---
231
+
232
+ ## How conditions are evaluated
233
+
234
+ `evaluateCondition({ condition, ctx })` (`conditions/evaluate.ts`) is the engine
235
+ entry point; it dispatches on `condition.type`. The `ctx` is a
236
+ `ConditionContext`:
237
+
238
+ ```ts
239
+ interface ConditionContext {
240
+ db: Database; // event/engagement legs query this
241
+ userId: string;
242
+ journeyContext: Record<string, unknown>; // property legs read from here
243
+ }
244
+ ```
245
+
246
+ You normally do NOT call `evaluateCondition` yourself — the engine runs it for
247
+ bucket `criteria`. For `trigger.where` / `exitOn[].where`, the engine uses
248
+ `evaluatePropertyConditions({ conditions, properties })`, which AND-s a
249
+ `PropertyCondition[]` against an event's properties (`.every(...)`). Knowing
250
+ which evaluator runs tells you which condition types a surface accepts (see
251
+ `references/examples.md`).
@@ -0,0 +1,90 @@
1
+ # Durations — `DurationObject` & the helpers
2
+
3
+ Every "how long" in Hogsend is a `DurationObject`, never a magic string. You
4
+ build them with `days()`, `hours()`, `minutes()` from `@hogsend/core`
5
+ (re-exported by `@hogsend/engine`).
6
+
7
+ ```ts
8
+ import { days, hours, minutes } from "@hogsend/core";
9
+ // or, alongside engine factories:
10
+ import { days, hours, minutes } from "@hogsend/engine";
11
+ ```
12
+
13
+ ## The shape
14
+
15
+ ```ts
16
+ interface DurationObject {
17
+ readonly hours?: number;
18
+ readonly minutes?: number;
19
+ readonly seconds?: number;
20
+ }
21
+ ```
22
+
23
+ ## The helpers (exact definitions)
24
+
25
+ ```ts
26
+ days(n) // => { hours: n * 24 } — yes, days are expressed as hours
27
+ hours(n) // => { hours: n }
28
+ minutes(n) // => { minutes: n }
29
+ ```
30
+
31
+ So `days(7)` is `{ hours: 168 }`. There is no `days` field on `DurationObject`
32
+ — `days()` normalizes to `hours`. Don't hand-write the object; the helpers keep
33
+ intent readable and are what the codebase uses everywhere.
34
+
35
+ `durationToMs(d)` is the conversion used internally
36
+ (`hours*3_600_000 + minutes*60_000 + seconds*1_000`). You rarely call it
37
+ directly; it backs `ctx.sleep` and the `within` window math.
38
+
39
+ ## Where durations are valid
40
+
41
+ A `DurationObject` is accepted anywhere the engine measures elapsed/remaining
42
+ time. The main spots a consumer touches:
43
+
44
+ | Location | Field | What it means |
45
+ |----------|-------|---------------|
46
+ | Journey meta | `suppress` | global cool-off after this journey runs |
47
+ | Journey meta | `entryPeriod` | window for `entryLimit: "once_per_period"` |
48
+ | Journey `run` | `ctx.sleep({ duration })` | durable wait inside a journey |
49
+ | Journey `run` | `ctx.history.hasEvent({ within })` | look-back window for an event check |
50
+ | Condition | `EventCondition.within` | rolling window on an `event` condition |
51
+ | Bucket meta | `entryPeriod` | window for the bucket's `entryLimit` |
52
+ | Bucket meta | `minDwell` / `maxDwell` | membership debounce floor / unconditional TTL |
53
+ | Bucket meta | `reconcileEvery` | advisory reconcile cadence (Studio display) |
54
+
55
+ ```ts
56
+ // Journey meta
57
+ meta: {
58
+ // ...
59
+ entryLimit: "once_per_period",
60
+ entryPeriod: days(3),
61
+ suppress: hours(4),
62
+ }
63
+
64
+ // Inside run()
65
+ await ctx.sleep({ duration: hours(2), label: "initial-followup" });
66
+ const { found } = await ctx.history.hasEvent({
67
+ userId: user.id,
68
+ event: Events.CHECKOUT_COMPLETED,
69
+ within: hours(26),
70
+ });
71
+
72
+ // On an event condition (bucket criteria)
73
+ { type: "event", eventName: "app.active", check: "not_exists", within: days(7) }
74
+ ```
75
+
76
+ ## Composing values
77
+
78
+ The helpers each return a single-field object, so to express "90 minutes" use
79
+ `minutes(90)`, not `hours(1)` plus `minutes(30)` — there's no add helper. Pick
80
+ the largest single unit that reads cleanly:
81
+
82
+ ```ts
83
+ minutes(90) // 90 minutes
84
+ hours(36) // a day and a half
85
+ days(14) // two weeks
86
+ ```
87
+
88
+ For the surfaces that consume these (journey orchestration, bucket dwell,
89
+ condition windows), see the hogsend-authoring-journeys and
90
+ hogsend-authoring-buckets skills.
@@ -0,0 +1,188 @@
1
+ # Copy-paste examples — `trigger.where`, `exitOn`, bucket `criteria`
2
+
3
+ Real, type-checked patterns built from the actual types and builder. Drop these
4
+ into `src/journeys/*.ts` and `src/buckets/*.ts`. Imports come from
5
+ `@hogsend/engine` / `@hogsend/core`; you never edit the engine.
6
+
7
+ ---
8
+
9
+ ## `trigger.where` — gate enrollment on event properties
10
+
11
+ `trigger.where` is a `PropertyCondition[]`. The conditions are AND-ed together
12
+ (`evaluatePropertyConditions` → `.every(...)`) against the TRIGGERING event's
13
+ properties. Only `property` conditions are valid here — there is no `within`,
14
+ no event count, no composite. (Need a count or a window? Put that logic in the
15
+ `run` body via `ctx.history.hasEvent`, or model it as a bucket.)
16
+
17
+ ```ts
18
+ import { days, hours } from "@hogsend/core";
19
+ import { defineJourney, sendEmail } from "@hogsend/engine";
20
+ import { Events, Templates } from "./constants/index.js";
21
+
22
+ export const proCheckoutAbandoned = defineJourney({
23
+ meta: {
24
+ id: "pro-checkout-abandoned",
25
+ name: "Pro plan — checkout abandoned",
26
+ enabled: true,
27
+ trigger: {
28
+ event: Events.CHECKOUT_ABANDONED,
29
+ // only fire for high-value abandons
30
+ where: [
31
+ { type: "property", property: "plan", operator: "eq", value: "pro" },
32
+ { type: "property", property: "cartValue", operator: "gte", value: 100 },
33
+ ],
34
+ },
35
+ entryLimit: "once_per_period",
36
+ entryPeriod: days(3),
37
+ suppress: hours(4),
38
+ },
39
+ run: async (user, ctx) => {
40
+ await ctx.sleep({ duration: hours(2), label: "nudge" });
41
+ await sendEmail({
42
+ to: user.email,
43
+ userId: user.id,
44
+ journeyStateId: user.stateId,
45
+ template: Templates.CONVERSION_WINBACK_OFFER,
46
+ subject: "Still thinking it over?",
47
+ journeyName: user.journeyName,
48
+ });
49
+ },
50
+ });
51
+ ```
52
+
53
+ ---
54
+
55
+ ## `exitOn` — leave the journey when a later event arrives
56
+
57
+ `exitOn` is an array of `{ event, where? }`. The engine matches the incoming
58
+ event name; if `where` is present it must pass (`PropertyCondition[]`, AND-ed)
59
+ for the exit to fire. Omit `where` to exit on the event name alone.
60
+
61
+ ```ts
62
+ meta: {
63
+ // ...
64
+ exitOn: [
65
+ // exit on any completed checkout
66
+ { event: Events.CHECKOUT_COMPLETED },
67
+ // exit on a subscription, but only if it's the pro plan
68
+ {
69
+ event: Events.SUBSCRIPTION_CREATED,
70
+ where: [
71
+ { type: "property", property: "plan", operator: "eq", value: "pro" },
72
+ ],
73
+ },
74
+ ],
75
+ }
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Bucket `criteria` — the full condition engine
81
+
82
+ `criteria` is a single `ConditionEval` tree and accepts ALL FOUR types. Author
83
+ it with the fluent builder `(b) => ...` (preferred) or as a declarative POJO.
84
+ Both forms produce identical data. A dynamic bucket MUST have at least one
85
+ POSITIVE condition (a pure-negation tree is rejected at registration).
86
+
87
+ ### Builder form (recommended)
88
+
89
+ ```ts
90
+ import { days, defineBucket } from "@hogsend/engine";
91
+ import { Events } from "../journeys/constants/index.js";
92
+
93
+ // Lapsed-active: was active once, but NOT in the last 7 days.
94
+ export const wentDormant = defineBucket({
95
+ meta: {
96
+ id: "went-dormant",
97
+ name: "Went dormant",
98
+ enabled: true,
99
+ timeBased: true,
100
+ fastExpiry: true,
101
+ criteria: (b) =>
102
+ b.all(
103
+ b.event(Events.APP_ACTIVE).exists(), // positive anchor
104
+ b.event(Events.APP_ACTIVE).within(days(7)).notExists(), // windowed absence
105
+ ),
106
+ },
107
+ });
108
+ ```
109
+
110
+ ### Mixing all four types
111
+
112
+ ```ts
113
+ import { days, defineBucket } from "@hogsend/engine";
114
+
115
+ export const atRiskPro = defineBucket({
116
+ meta: {
117
+ id: "at-risk-pro",
118
+ name: "At-risk pro accounts",
119
+ enabled: true,
120
+ timeBased: true,
121
+ criteria: (b) =>
122
+ b.all(
123
+ b.prop("plan").eq("pro"), // property
124
+ b.event("app.active").within(days(14)).lessThan(3), // event count + window
125
+ b.any(
126
+ b.event("support.ticket").within(days(30)).exists(),
127
+ // email_engagement has no builder terminal — drop the POJO in directly:
128
+ { type: "email_engagement", templateKey: "renewal-reminder", check: "not_opened" },
129
+ ),
130
+ ),
131
+ },
132
+ });
133
+ ```
134
+
135
+ ### Declarative form (identical result)
136
+
137
+ ```ts
138
+ export const wentDormantDeclarative = defineBucket({
139
+ meta: {
140
+ id: "went-dormant",
141
+ name: "Went dormant",
142
+ enabled: true,
143
+ timeBased: true,
144
+ criteria: {
145
+ type: "composite",
146
+ operator: "and",
147
+ conditions: [
148
+ { type: "event", eventName: "app.active", check: "exists" },
149
+ { type: "event", eventName: "app.active", check: "not_exists", within: days(7) },
150
+ ],
151
+ },
152
+ },
153
+ });
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Reusable fragments via `criteriaBuilder`
159
+
160
+ Import the builder standalone to compose shared criteria pieces (handy in tests
161
+ or to DRY up several buckets):
162
+
163
+ ```ts
164
+ import { criteriaBuilder as b, type ConditionEval } from "@hogsend/core";
165
+
166
+ const isPro: ConditionEval = b.prop("plan").eq("pro");
167
+ const wentQuiet = (window: ReturnType<typeof days>): ConditionEval =>
168
+ b.event("app.active").within(window).notExists();
169
+
170
+ // use inside a bucket
171
+ criteria: (c) => c.all(isPro, wentQuiet(days(7))),
172
+ ```
173
+
174
+ ---
175
+
176
+ ## Quick decision guide
177
+
178
+ - Gating who ENTERS a journey on event properties → `trigger.where`
179
+ (`PropertyCondition[]`).
180
+ - Pulling someone OUT of a journey when a later event lands → `exitOn`
181
+ (`{ event, where? }`, `where` is `PropertyCondition[]`).
182
+ - "Is the user in this segment right now?" with counts / windows / engagement →
183
+ a bucket `criteria` tree (full `ConditionEval`). See the
184
+ hogsend-authoring-buckets skill.
185
+ - A look-back check mid-journey (count or window) → `ctx.history.hasEvent` in
186
+ the `run` body. See the hogsend-authoring-journeys skill.
187
+ - Confirm a condition is actually firing on a running instance → see the
188
+ hogsend-cli skill.
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: hogsend-database
3
+ description: Use when changing the database schema or running migrations in a Hogsend app — adding your own (client-track) tables in src/schema/ with Drizzle pgTable then db:generate + db:migrate, understanding the two-track system (engine-owned tables in @hogsend/db gate boot; your client tables are non-fatal), reading schema drift off /v1/health, using db:push as a dev shortcut, or bypassing the boot guard with SKIP_SCHEMA_CHECK.
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Hogsend Database
11
+
12
+ Hogsend runs **two independent migration tracks** against one Postgres
13
+ database. As a consumer (a scaffolded, content-only app) you own exactly one of
14
+ them: the **client track** — your own tables in `src/schema/index.ts`, your own
15
+ migrations in `./migrations`. The other — the **engine track** — is owned by
16
+ `@hogsend/db`, ships with `@hogsend/*` version bumps, and you never author it.
17
+
18
+ This skill helps you add tables, generate + apply migrations, and read schema
19
+ drift correctly without touching engine-internal files.
20
+
21
+ ## Key concepts (read this first)
22
+
23
+ - **You own `src/schema/index.ts` only.** Define your app tables there with
24
+ Drizzle's `pgTable`. `pnpm db:generate` diffs that file and writes a migration
25
+ into `./migrations`; `pnpm db:migrate` applies it.
26
+ - **Engine tables live in `@hogsend/db`, not your repo.** `contacts`,
27
+ `journeyStates`, `emailSends`, `trackedLinks`, `linkClicks`,
28
+ `emailPreferences`, `bucketMemberships`, `userEvents`, auth tables, and the
29
+ rest are engine-owned. **Never redefine them in `src/schema/`** — import them
30
+ from `@hogsend/db` if you need to read/join them.
31
+ - **Two ledgers, one `drizzle` schema.** Engine track records into
32
+ `drizzle.__drizzle_migrations`; client track into `drizzle.__client_migrations`.
33
+ Your `drizzle.config.ts` is wired to the client ledger, so `db:generate`
34
+ can never collide with the engine's.
35
+ - **Drift gating is asymmetric.** Engine-track drift is **fatal at boot** — the
36
+ running build hard-requires its bundled engine schema. Client-track drift is
37
+ **non-fatal**; once you wire `clientJournal` into `createHogsendClient` it
38
+ surfaces on `GET /v1/health` as `status: "migration_pending"` for you to
39
+ resolve (the block is opt-in — see `references/migrations.md`). You may
40
+ legitimately deploy app code ahead of an additive client migration.
41
+
42
+ ## The everyday flow
43
+
44
+ ```bash
45
+ pnpm db:generate # diff src/schema/index.ts -> new file in ./migrations
46
+ pnpm db:migrate # apply ENGINE track first, then your CLIENT track
47
+ # then confirm both tracks are inSync on GET /v1/health
48
+ ```
49
+
50
+ `scripts/migrate.ts` always runs engine-then-client (engine first so your client
51
+ tables can reference engine tables). Railway's `preDeployCommand` runs the same
52
+ `pnpm db:migrate` before every deploy.
53
+
54
+ ## Task playbooks — load the matching reference
55
+
56
+ - **Add / change your own tables; what you may and may NOT touch** →
57
+ `references/client-track-schema.md`
58
+ - **The db:generate → db:migrate flow, drizzle.config, the migrations dir +
59
+ meta journal, the db:push shortcut** → `references/migrations.md`
60
+ - **Schema drift: fatal engine-track at boot vs non-fatal client-track on
61
+ /v1/health, SKIP_SCHEMA_CHECK, recovering a db:push'd ledger** →
62
+ `references/schema-drift.md`
63
+
64
+ ## Cross-skill links
65
+
66
+ - Verifying a running instance's schema tracks (`schema.engine` /
67
+ `schema.client`, `inSync`) from the CLI — see the **hogsend-cli** skill
68
+ (`hogsend doctor --json`).
69
+ - Querying with conditions / property checks / time windows over engine tables
70
+ (events, email engagement) — see the **hogsend-conditions** skill.
@@ -0,0 +1,97 @@
1
+ # Client-track schema — what you own
2
+
3
+ Your app's tables live in **`src/schema/index.ts`** and migrate on the **client
4
+ track**. This is the only schema file you author. Engine tables live in
5
+ `@hogsend/db` and are off-limits to redefine.
6
+
7
+ ## What you own vs what the engine owns
8
+
9
+ | | Owner | Where it lives | Ledger | Drift gating |
10
+ |---|---|---|---|---|
11
+ | **Client tables** (your app data) | You | `src/schema/index.ts` → `./migrations` | `drizzle.__client_migrations` | non-fatal → `/v1/health` |
12
+ | **Engine tables** (`contacts`, `journeyStates`, `emailSends`, `trackedLinks`, `linkClicks`, `emailPreferences`, `bucketMemberships`, `userEvents`, auth, alert/import/dlq tables, …) | `@hogsend/db` | inside the published `@hogsend/db` package | `drizzle.__drizzle_migrations` | **fatal at boot** |
13
+
14
+ **Rule:** never re-declare an engine table (`contacts`, `journey_states`,
15
+ `email_sends`, etc.) in `src/schema/`. If you do, `db:generate` will try to
16
+ create a table the engine already manages and your client migration will collide
17
+ with engine objects. Import them from `@hogsend/db` instead.
18
+
19
+ ## Adding a table
20
+
21
+ Open `src/schema/index.ts` and add a `pgTable`. The scaffold ships a starter
22
+ table (`clientNotes`) you can rename or replace:
23
+
24
+ ```ts
25
+ // src/schema/index.ts
26
+ import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
27
+
28
+ /**
29
+ * CLIENT-track schema. Engine tables (contacts, journeyStates, emailSends,
30
+ * tracking, ...) live in @hogsend/db and migrate on the ENGINE track — do NOT
31
+ * redefine them here. Add only your own app-specific tables.
32
+ */
33
+ export const supportTickets = pgTable(
34
+ "support_tickets",
35
+ {
36
+ id: uuid("id").primaryKey().defaultRandom(),
37
+ // Match the engine's contact identity: contacts.externalId is the user id
38
+ // you pass to ingest/journeys. Keep it text, not a FK, to stay decoupled.
39
+ userId: text("user_id").notNull(),
40
+ subject: text("subject").notNull(),
41
+ status: text("status").notNull().default("open"),
42
+ createdAt: timestamp("created_at", { withTimezone: true })
43
+ .defaultNow()
44
+ .notNull(),
45
+ },
46
+ (table) => [index("support_tickets_user_id_idx").on(table.userId)],
47
+ );
48
+ ```
49
+
50
+ Then generate + apply (see `migrations.md`):
51
+
52
+ ```bash
53
+ pnpm db:generate
54
+ pnpm db:migrate
55
+ ```
56
+
57
+ ## Conventions worth matching
58
+
59
+ Engine tables use these patterns; mirroring them keeps your schema consistent:
60
+
61
+ - **`uuid("id").primaryKey().defaultRandom()`** for surrogate keys.
62
+ - **`timestamp(..., { withTimezone: true })`** — always timezone-aware.
63
+ - **`text("user_id")`** to reference a contact by its external id (the same id
64
+ you pass to `ctx.trigger`/ingest). Engine tables denormalize `user_id` as
65
+ plain `text` rather than a hard FK to `contacts`, so you stay decoupled from
66
+ engine internals — do the same.
67
+ - Add `index(...)` on the columns you filter/sort by.
68
+
69
+ ## Reading engine tables from your code
70
+
71
+ You don't redefine engine tables — you import them. The engine container exposes
72
+ the schema-aware Drizzle db; engine table objects come from `@hogsend/db`:
73
+
74
+ ```ts
75
+ // e.g. inside a custom workflow or route handler
76
+ import { contacts, emailSends } from "@hogsend/db";
77
+ import { eq } from "drizzle-orm";
78
+
79
+ // `db` is the container's Drizzle instance (c.get("container").db, or
80
+ // client.db). It already knows the full engine + your client schema.
81
+ const rows = await db
82
+ .select()
83
+ .from(contacts)
84
+ .where(eq(contacts.externalId, "user_123"));
85
+ ```
86
+
87
+ Joining your client table to an engine table (e.g. `support_tickets.userId` ↔
88
+ `contacts.externalId`) works because both are registered on the same Drizzle
89
+ instance — your client schema is bundled into the consumer build alongside
90
+ `@hogsend/db`.
91
+
92
+ ## Don't edit
93
+
94
+ - Anything under `@hogsend/db` (it's a dependency, not your source).
95
+ - The engine ledger `drizzle.__drizzle_migrations`.
96
+ - The engine's `drizzle.config` / migration folder — you only have your own
97
+ client `drizzle.config.ts` and `./migrations`.