@cowliss/cli 0.6.0 → 0.8.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.
@@ -4,21 +4,57 @@ import { z } from "zod";
4
4
  /** The public docs site, for the mail and the pages that point people at it. */
5
5
  const DOCS_URL = "https://docs.cowliss.com";
6
6
  /**
7
+ * Stripe-style ID prefixes per resource type, so ids in logs, URLs, and
8
+ * payloads are self-describing.
9
+ */
10
+ const ID_PREFIXES = {
11
+ user: "usr_",
12
+ event: "evt_",
13
+ identifier: "idn_",
14
+ segment: "seg_",
15
+ /** The attribution unit: a readable `app_<slug>`, not a TypeID. */
16
+ app: "app_",
17
+ /** One inbound pipe into an app; a TypeID like everything else. */
18
+ source: "src_",
19
+ webhook: "whk_",
20
+ push: "psh_",
21
+ version: "ver_",
22
+ execution: "exe_",
23
+ delivery: "dlv_",
24
+ violation: "vio_",
25
+ quarantineEntry: "qtn_",
26
+ idempotencyKey: "idk_",
27
+ abuseEvent: "abu_",
28
+ suppression: "sup_",
29
+ senderDomain: "dom_",
30
+ journeyRun: "run_",
31
+ topup: "top_",
32
+ emailAddress: "eml_"
33
+ };
34
+ /**
7
35
  * Clerk ID prefix marking an organization-scoped subject. Ingestion API keys
8
36
  * must resolve to an org subject; user-scoped keys are rejected.
9
37
  */
10
38
  const CLERK_ORG_SUBJECT_PREFIX = "org_";
11
39
  /**
12
- * Fixed consent purposes for the prototype. Consent is a per-purpose map on
13
- * the profile, checked at send-step execution time.
14
- *
15
- * The two names describe what the recipient agreed to, not the pipe it
16
- * arrives on: "emails I did not ask for individually" and "my data leaving
17
- * for somewhere else". Naming them after the channel (`email`, `webhook`)
18
- * said nothing a recipient could consent to, and transactional mail already
19
- * bypasses the email purpose, so it was only ever marketing consent.
40
+ * The marketing purpose by name, since it is the one every gate, the
41
+ * unsubscribe route, and the developer's own toggle all reach for.
20
42
  */
21
- const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
43
+ const MARKETING = "marketing";
44
+ /**
45
+ * A legal journey `purpose` that is not a consent purpose: the send gates
46
+ * pass it unconditionally, no profile's map stores it and no editor renders
47
+ * it. What protects the channel itself (suppression, the SES gates, the
48
+ * quota) still applies.
49
+ */
50
+ const TRANSACTIONAL = "transactional";
51
+ /**
52
+ * The purposes a project may not declare, which are the same two that sit
53
+ * outside the `marketing` umbrella: `marketing` is the umbrella itself and
54
+ * `transactional` is the case consent does not govern. Everything a project
55
+ * declares is a marketing-mail category and so sits under it.
56
+ */
57
+ const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
22
58
  /**
23
59
  * The two classes of send, declared on the template rather than passed per
24
60
  * call so the class cannot drift between two sends of the same message.
@@ -56,6 +92,82 @@ const PUSH_LIMITS = {
56
92
  const COW_CONFIG_SCHEMA_PATH = "/schemas/cow.json";
57
93
  const COW_CONFIG_SCHEMA_URL = `${DOCS_URL}${COW_CONFIG_SCHEMA_PATH}`;
58
94
 
95
+ //#endregion
96
+ //#region ../../packages/shared/src/segment-definition.ts
97
+ /**
98
+ * A segment definition: a flat list of predicates over traits and event
99
+ * history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
100
+ * prototype, and a flat list keeps the pure evaluator a fold.
101
+ *
102
+ * A definition names no app (ADR 0022): the segment row's own `app_id` is
103
+ * the one app it evaluates, so the events and the profiles it reads are
104
+ * that app's and there is nothing to filter by here.
105
+ *
106
+ * Apart from `./segments` because a journey's trigger carries a definition
107
+ * and the trigger schema is bundled into the wasm guest, where an edge to
108
+ * `@cowliss/db` (which `./segments` has, for the row schema) is a hard
109
+ * bundler failure. Nothing here imports anything but zod.
110
+ *
111
+ * The object is strict: a definition holding the retired `sourceId` or
112
+ * `appId` key (both of which meant the app) fails loudly instead of parsing
113
+ * as an unfiltered definition that evaluates over every app. There is deliberately no pipe
114
+ * filter here — filtering by source is a later feature, and accepting one
115
+ * now would make a stale `sourceId` parse as a filter on a pipe that does
116
+ * not exist and silently match nothing.
117
+ */
118
+ const SEGMENT_TRAIT_OPS = [
119
+ "eq",
120
+ "neq",
121
+ "gt",
122
+ "gte",
123
+ "lt",
124
+ "lte",
125
+ "exists",
126
+ "notExists",
127
+ "contains"
128
+ ];
129
+ /** Operators that read no comparison value: presence of the key is the test. */
130
+ const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
131
+ const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
132
+ const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
133
+ kind: z.literal("trait"),
134
+ name: predicateNameSchema,
135
+ op: z.enum(SEGMENT_TRAIT_OPS),
136
+ /**
137
+ * Compared against the stored trait, which is `unknown` because
138
+ * identify accepts arbitrary JSON. The evaluator coerces both sides
139
+ * before comparing, so authors here (CLI, MCP, dashboard) get the
140
+ * form-field-friendly reading rather than strict JSON equality:
141
+ * numeric-looking strings are compared as numbers for eq/neq and for
142
+ * ordering ("150" matches 150, and orders like it), and "true"/"false"
143
+ * are compared as booleans for eq/neq, trimmed and case-insensitively
144
+ * (" TRUE " reads as true). Coercion needs both sides to agree on a
145
+ * type: "0" never equals false. The numeric net is as wide as
146
+ * `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
147
+ * matters most for opaque ids: "007" is authored as the number 7.
148
+ *
149
+ * contains reads three ways. Against an array trait it is membership
150
+ * under that same equality, so "true" matches `[true]` and "1" matches
151
+ * `[1, 2]`. Against a string trait it is a plain substring search with
152
+ * no coercion, since substrings only mean something between strings.
153
+ * Against anything else it never matches. Ordering never coerces
154
+ * booleans.
155
+ */
156
+ value: z.unknown().optional()
157
+ }), z.object({
158
+ kind: z.literal("event"),
159
+ name: predicateNameSchema,
160
+ op: z.enum(["performed", "notPerformed"]),
161
+ /** How many matching events the predicate counts as "performed". */
162
+ atLeast: z.number().int().min(1).default(1),
163
+ /** Rolling window, relative to evaluation time; absent means all history. */
164
+ withinDays: z.number().int().min(1).optional()
165
+ })]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
166
+ const segmentDefinitionSchema = z.strictObject({
167
+ match: z.enum(["all", "any"]).default("all"),
168
+ predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
169
+ });
170
+
59
171
  //#endregion
60
172
  //#region ../../packages/shared/src/journeys-v2/manifest.ts
61
173
  /**
@@ -65,6 +177,34 @@ const COW_CONFIG_SCHEMA_URL = `${DOCS_URL}${COW_CONFIG_SCHEMA_PATH}`;
65
177
  * changed, and stores that entry on the version it creates.
66
178
  */
67
179
  /**
180
+ * A duration as journey code writes it: an ms-style string ("2d") or
181
+ * milliseconds. It lives here rather than with the guest protocol because a
182
+ * manifest carries one too (a journey's enrollment cooldown) and `./guest`
183
+ * already imports this module, so the other direction would be a cycle.
184
+ */
185
+ const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
186
+ const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
187
+ const DURATION_UNIT_MS = {
188
+ ms: 1,
189
+ s: 1e3,
190
+ m: 6e4,
191
+ h: 36e5,
192
+ d: 864e5,
193
+ w: 6048e5
194
+ };
195
+ /**
196
+ * A duration in milliseconds. The simulator's virtual clock and the runner's
197
+ * timers both need it, and neither may pull in Temporal's `msToNumber` (one
198
+ * runs in the CLI, the other inside workflow code).
199
+ */
200
+ function parseDuration(duration) {
201
+ if (typeof duration === "number") return duration;
202
+ const match = DURATION_PATTERN.exec(duration.trim());
203
+ if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
204
+ const unit = DURATION_UNIT_MS[match[2]];
205
+ return Math.round(Number(match[1]) * unit);
206
+ }
207
+ /**
68
208
  * A journey or template key: the file basename under `journeys/` or
69
209
  * `emails/`, kebab-case and unique across the project. Becomes part of the
70
210
  * Temporal workflow id and travels in the journey chain, so it stays short.
@@ -85,8 +225,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
85
225
  */
86
226
  const tagsSchema = z.array(z.string().trim().min(1).max(50, "a tag must be at most 50 characters")).max(20, "a journey or template takes at most 20 tags").default([]).transform((tags) => [...new Set(tags)]);
87
227
  /**
88
- * A consent purpose key: camelCase, matching the fixed `emailMarketing` and
89
- * `dataProcessing`. Purposes are keys in the `consent` map a customer reads
228
+ * A consent purpose key: camelCase, matching the reserved `marketing` and
229
+ * `transactional`. Purposes are keys in the `consent` map a customer reads
90
230
  * on their own profile, which is why they are not the kebab-case of a
91
231
  * journey key.
92
232
  */
@@ -101,22 +241,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
101
241
  const consentPurposeKeySchema = z.string().max(50, "a purpose key must be at most 50 characters").regex(CONSENT_PURPOSE_KEY_PATTERN, "a consent purpose key must be camelCase (a letter first, then letters and digits)");
102
242
  /**
103
243
  * One purpose a project declares in `cow.json` (spec: Decisions). A declared
104
- * purpose is marketing-class and sits under the `emailMarketing` umbrella,
105
- * so `denied` is the only default it may carry: the purpose is absent on
106
- * every profile that already exists, and a granted default would answer for
107
- * all of them at once.
244
+ * purpose is marketing-class and sits under the `marketing` umbrella, so
245
+ * `denied` is the only default it may carry: the purpose is absent on every
246
+ * profile that already exists, and a granted default would answer for all of
247
+ * them at once.
108
248
  *
109
249
  * The field stays required rather than disappearing, so every `cow.json` and
110
- * every stored manifest written before this still parses, and the column
111
- * behind it still holds `granted` for the seeded `dataProcessing` row: this
112
- * is a refusal at declaration time, not a narrower storage shape.
250
+ * every stored manifest written before this still parses: this is a refusal
251
+ * at declaration time, not a narrower storage shape.
113
252
  *
114
- * The two fixed purposes cannot be declared. They are seeded for every org
115
- * and owned by no project, so a project redeclaring one would be renaming
116
- * the master switch every other project's journeys hang off.
253
+ * Neither reserved purpose can be declared: `marketing` is the master switch
254
+ * every other project's journeys hang off, and `transactional` is not a
255
+ * consent purpose at all, so declaring it would promise a switch that no
256
+ * send ever reads.
117
257
  */
118
258
  const declaredPurposeSchema = z.strictObject({
119
- key: consentPurposeKeySchema.refine((key) => !CONSENT_PURPOSES.includes(key), `${CONSENT_PURPOSES.join(" and ")} are fixed purposes and cannot be declared`),
259
+ key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
120
260
  /** What the dashboard and the account modal render beside the switch. */
121
261
  label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
122
262
  /** What the purpose means for a profile whose map does not answer it. */
@@ -137,19 +277,27 @@ const purposesSchema = z.array(declaredPurposeSchema).max(20, "a project declare
137
277
  const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 200 characters");
138
278
  const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
139
279
  /**
140
- * What starts a journey: an event (optionally narrowed to an app id or a
141
- * list of them) or a segment entry. The registry DTO in `../journeys`
142
- * reuses it.
280
+ * What starts a journey: an event, or entry into the segment the trigger
281
+ * itself describes. The registry DTO in `../journeys` reuses it.
282
+ *
283
+ * No trigger names an app (ADR 0022): a repository is one app, the journey
284
+ * row carries its `app_id`, and the runtime compares that with the event's.
285
+ * An `appId` here would be a second way to say it, and the way to leak.
286
+ *
287
+ * A segment trigger carries the predicate list, not a name: the push
288
+ * materializes one segment row per journey that inlines a definition, owned
289
+ * by the journey and named after its key, so the segment exists because the
290
+ * journey exists and there is no order to get wrong (ADR 0016). The
291
+ * definition is data — validated here, carried on the version, never
292
+ * compiled and never executed.
143
293
  *
144
- * Both members are strict, so a journey holding the retired `source` key
145
- * fails to compile instead of silently triggering on every app.
146
- * There is deliberately no pipe filter: a trigger narrows by the app the
147
- * write is attributed to, the same token a segment definition names.
294
+ * Both members are strict, so a journey holding the retired `source` or
295
+ * `appId` key, or the retired `{ segment: "name" }` form, fails to build
296
+ * instead of silently triggering on every app or on nothing. There is
297
+ * deliberately no pipe filter and no app filter: the journey's own app is
298
+ * the only narrowing there is.
148
299
  */
149
- const triggerSchema = z.union([z.strictObject({
150
- event: patternSchema,
151
- appId: patternSchema.optional()
152
- }), z.strictObject({ segment: z.string().min(1) })]);
300
+ const triggerSchema = z.union([z.strictObject({ event: patternSchema }), z.strictObject({ segment: segmentDefinitionSchema })]);
153
301
  /**
154
302
  * The address half of a from-header: a local part, an `@`, and a dotted
155
303
  * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
@@ -252,10 +400,35 @@ const manifestJourneySchema = z.object({
252
400
  */
253
401
  purpose: consentPurposeKeySchema,
254
402
  /**
403
+ * How often one recipient may enter. Enrollment derives from the purpose
404
+ * (ADR 0015), so the only thing an author writes is how long after a
405
+ * completed run the journey re-opens: absent means once, ever. Refused on
406
+ * a transactional journey, which enrolls on every trigger — see
407
+ * `manifestSchema` below, where the cross-field check lives (a refinement
408
+ * on this object would break the `.pick()` the guest SDK and the guest
409
+ * protocol both take of it).
410
+ */
411
+ enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
412
+ try {
413
+ parseDuration(value);
414
+ return true;
415
+ } catch {
416
+ return false;
417
+ }
418
+ }, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
419
+ /**
420
+ * The author's own sentence about what this journey does, shown wherever
421
+ * the journey is read. Capped like a segment's description; absent when
422
+ * the author wrote none.
423
+ */
424
+ description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
425
+ /**
255
426
  * The address every `send.email` in this journey goes out as, unless the
256
- * call names its own. Optional everywhere: an omitted one is the
257
- * organization's shared fallback address, which is what makes day-one
258
- * sending zero-config (ADR 0014).
427
+ * call names its own. Required of the author (`defineJourney` types it so,
428
+ * and `cow build` refuses a journey without it), and still optional here:
429
+ * releases pushed before ADR 0014 was amended carry none, and this schema
430
+ * parses stored manifests as well as new ones. A push with no `from` is
431
+ * refused by the domain gate, which says what to do about it.
259
432
  */
260
433
  from: fromSchema.optional(),
261
434
  spine: z.array(spineEntrySchema),
@@ -268,6 +441,13 @@ const manifestTemplateSchema = z.object({
268
441
  sendClass: z.enum(SEND_CLASSES),
269
442
  /** True asks the host to mint a signed `verifyUrl` prop at send time. */
270
443
  verifyLink: z.boolean(),
444
+ /**
445
+ * True asks the host to mint a signed `unsubscribeUrl` prop at send time,
446
+ * for the author's own footer link. Default false: a marketing send whose
447
+ * HTML carries no unsubscribe link at all still gets one, appended as a
448
+ * platform footer, so the link is never the author's to forget.
449
+ */
450
+ unsubscribeLink: z.boolean().default(false),
271
451
  /** JSON Schema of the template's `props`, converted by `cow build`. */
272
452
  propsSchema: z.record(z.string(), z.unknown()),
273
453
  bundle: digestSchema
@@ -318,6 +498,15 @@ const manifestSchema = z.object({
318
498
  uniqueKeys(manifest.journeys, ctx, "journeys");
319
499
  uniqueKeys(manifest.templates, ctx, "templates");
320
500
  uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
501
+ for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
502
+ code: "custom",
503
+ message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
504
+ path: [
505
+ "journeys",
506
+ index,
507
+ "enrollment"
508
+ ]
509
+ });
321
510
  });
322
511
  /**
323
512
  * One entry of a stored manifest: what a version is a snapshot of. Journeys
@@ -330,30 +519,56 @@ const versionManifestSchema = z.union([manifestJourneySchema, manifestTemplateSc
330
519
  //#endregion
331
520
  //#region ../../packages/shared/src/journeys-v2/config.ts
332
521
  /**
333
- * A project's name: what `cow init` asked for, and what the `projects` row
334
- * stores. Lives here rather than beside the release DTOs because `cow.json`
335
- * is the file a developer types it into; `createProjectBodySchema` reuses it.
522
+ * An app id as `cow.json` carries it: the readable `app_<slug>`
523
+ * `appIdFromName` derives, which is the one id a developer types by hand.
524
+ *
525
+ * It lives here rather than beside the app DTOs in ../apps because that
526
+ * module imports the Drizzle schema, which neither the wasmtime guest nor
527
+ * the Temporal workflow isolate can carry, and because `cow.json` is the
528
+ * file a developer types it into. Everything that names an app on the wire
529
+ * reuses it.
530
+ */
531
+ const appIdSchema = z.string().trim().max(200).regex(new RegExp(`^${ID_PREFIXES.app}[a-z0-9]+(-[a-z0-9]+)*$`), "an app id looks like \"app_website\"");
532
+ /**
533
+ * A source id: the one inbound pipe an ingestion or send call names. It
534
+ * lives beside `appIdSchema` for the same two reasons — the sources module
535
+ * imports the Drizzle schema, and both are ids that travel on the wire — and
536
+ * it has the same shape, so a bare `src_` is refused here rather than at the
537
+ * database.
538
+ *
539
+ * The prefixes above and here are interpolated raw; `journeys-v2.test.ts`
540
+ * asserts they are plain `<word>_` literals, so none of them can quietly
541
+ * change what these patterns match.
336
542
  */
337
- const projectNameSchema = z.string().trim().min(1).max(200);
543
+ const sourceIdSchema = z.string().trim().max(200).regex(new RegExp(`^${ID_PREFIXES.source}[a-z0-9_]+$`), "a source id looks like \"src_01j8x…\"");
338
544
  /**
339
- * `cow.json` (spec: Project layout): the org, and which of its projects this
340
- * directory is. Auth never lives in the project. Strict, so a typo'd key
341
- * is a build error rather than a silently ignored setting.
545
+ * A Clerk organization id, as `cow.json` spells it. Exported beside
546
+ * `appIdSchema` because the push body carries the org the file names so the
547
+ * server can refuse a push aimed at another one (ADR 0022).
548
+ */
549
+ const orgIdSchema = z.string().trim().max(200).startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id");
550
+ /**
551
+ * `cow.json` (spec: Project layout): the org, and which of its apps this
552
+ * repository pushes to. Auth never lives in the file. Strict, so a typo'd
553
+ * key is a build error rather than a silently ignored setting — which is
554
+ * also what turns the retired `project` key into a refusal (`cow build`
555
+ * says what to rename it to).
342
556
  */
343
557
  const cowConfigSchema = z.strictObject({
344
558
  $schema: z.url().optional(),
345
- orgId: z.string().startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id"),
559
+ orgId: orgIdSchema,
346
560
  /**
347
- * Which project of the org this directory pushes to. One org can hold
348
- * several, one per repo, each with its own release sequence and its own
349
- * slice of the deployed journeys, so every project says which it is.
561
+ * Which app of the org this repository pushes to (ADR 0022). The app is
562
+ * the push unit: it owns the journey and template keys, and `cow
563
+ * status`, `cow enable --all` and `cow pull` all resolve against it. One
564
+ * repository is one app, so no journey file names one.
350
565
  */
351
- project: projectNameSchema,
566
+ appId: appIdSchema,
352
567
  /**
353
- * The consent purposes this project declares. They are org-wide, so two
354
- * projects declaring one key must agree on its label and default or the
355
- * deploy is refused; a deploy adds and updates them and never deletes
356
- * one, because profiles hold answers against them.
568
+ * The consent purposes this repository declares. They are org-wide, so
569
+ * two apps declaring one key must agree on its label and default or the
570
+ * push is refused; a push adds and updates them and never deletes one,
571
+ * because profiles hold answers against them.
357
572
  */
358
573
  purposes: purposesSchema.optional(),
359
574
  /** Overrides the API the CLI talks to; the hosted product needs none. */
@@ -366,7 +581,7 @@ const cowConfigSchema = z.strictObject({
366
581
  webUrl: z.url().optional()
367
582
  }).meta({
368
583
  title: "cow.json",
369
- description: "A cow project: the organization and the project it pushes to."
584
+ description: "A cow project: the organization and the app it pushes to."
370
585
  });
371
586
 
372
587
  //#endregion
@@ -377,8 +592,6 @@ const cowConfigSchema = z.strictObject({
377
592
  * a guest returns with these schemas before anything acts on it; the guest
378
593
  * SDK and the Node simulator produce and consume the same shapes.
379
594
  */
380
- /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
381
- const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
382
595
  /** The event a journey runs for, or waits on: name, properties, and when. */
383
596
  const guestEventSchema = z.object({
384
597
  name: z.string().min(1),
@@ -418,8 +631,9 @@ const commandSchema = z.discriminatedUnion("name", [
418
631
  /**
419
632
  * The address this mail leaves as. The guest SDK fills in the
420
633
  * journey's own whenever the call does not name one, so the host has
421
- * one resolution path and never reads the manifest to find it;
422
- * absent on both is the organization's shared fallback address.
634
+ * one resolution path and never reads the manifest to find it. A
635
+ * journey must name one, so absent on both is a release pushed before
636
+ * that was true, and the `domain` gate refuses it.
423
637
  */
424
638
  from: fromSchema.optional(),
425
639
  /** Where a reply to this one mail goes, instead of the from-address. */
@@ -563,11 +777,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
563
777
  const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
564
778
  trigger: true,
565
779
  purpose: true,
780
+ enrollment: true,
781
+ description: true,
566
782
  from: true,
567
783
  tags: true
568
784
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
569
785
  sendClass: true,
570
786
  verifyLink: true,
787
+ unsubscribeLink: true,
571
788
  propsSchema: true,
572
789
  tags: true
573
790
  }).extend({ kind: z.literal("template") })]);
@@ -683,6 +900,8 @@ var CapabilityError = class extends Error {
683
900
  const journeyConfigSchema = manifestJourneySchema.pick({
684
901
  trigger: true,
685
902
  purpose: true,
903
+ enrollment: true,
904
+ description: true,
686
905
  from: true,
687
906
  tags: true
688
907
  });
@@ -690,16 +909,25 @@ const journeyConfigSchema = manifestJourneySchema.pick({
690
909
  * Author a journey. Throws at definition time on an invalid config.
691
910
  *
692
911
  * There is no environment gate here: an organization has one data space, and
693
- * a journey runs wherever it is enabled and nowhere else (ADR 0011, ADR 0013).
912
+ * a journey runs wherever it is on and nowhere else (ADR 0011, ADR 0013).
913
+ *
914
+ * There is no app gate either. A journey belongs to the one app `cow.json`
915
+ * names, so it only ever sees that app's events, profiles and segments
916
+ * (ADR 0022); a trigger naming an app is refused rather than honoured.
694
917
  */
695
918
  function defineJourney(input) {
919
+ const config = journeyConfigSchema.parse({
920
+ trigger: input.trigger,
921
+ purpose: input.purpose,
922
+ enrollment: input.enrollment,
923
+ description: input.description,
924
+ from: input.from,
925
+ tags: input.tags
926
+ });
927
+ if (config.from === void 0) throw new Error("defineJourney needs a \"from\": the address this journey sends as, on a domain your organization verified. Add and verify it on the Domains page (Settings, Domains). For example from: \"Billing <billing@your-domain.com>\".");
696
928
  return {
697
- ...journeyConfigSchema.parse({
698
- trigger: input.trigger,
699
- purpose: input.purpose,
700
- from: input.from,
701
- tags: input.tags
702
- }),
929
+ ...config,
930
+ from: config.from,
703
931
  run: input.run
704
932
  };
705
933
  }
@@ -1,4 +1,4 @@
1
- import { n as Duration, r as GuestEvent, t as Trigger } from "./index-CXcCEcAg.js";
1
+ import { i as GuestEvent, n as Trigger, r as TriggerInput, t as Duration } from "./index-EqBCZnpq.js";
2
2
  //#region src/guest/journeys.d.ts
3
3
  /** The event a journey runs for, or the one a `waitForEvent` resolved with. */
4
4
  type Event = GuestEvent;
@@ -32,10 +32,10 @@ declare class CapabilityError extends Error {
32
32
  constructor(code: string, message: string);
33
33
  }
34
34
  /**
35
- * The project's templates, keyed by template key. `cow build` writes the
35
+ * The repository's templates, keyed by template key. `cow build` writes the
36
36
  * real interface into `.cow/types.d.ts` and TypeScript merges it into this
37
37
  * one, which is what types `api.send.email`. Empty here on purpose: a
38
- * project that has not built yet still compiles, with string keys.
38
+ * repository that has not built yet still compiles, with string keys.
39
39
  */
40
40
  interface CowTemplates {}
41
41
  type TemplateKey = keyof CowTemplates extends never ? string : keyof CowTemplates;
@@ -121,22 +121,41 @@ type Api = {
121
121
  type JourneyConfig = {
122
122
  trigger: Trigger;
123
123
  /**
124
- * A fixed purpose (`emailMarketing`, `dataProcessing`) or one this
125
- * project declares in `cow.json`. The key's shape is checked here; that
126
- * the org declares it is checked when the release is deployed.
124
+ * Why this journey contacts someone: `marketing`, `transactional`, or a
125
+ * purpose this repository declares in `cow.json`. The key's shape is
126
+ * checked here; that the org declares it is checked when the push lands.
127
127
  */
128
128
  purpose: string;
129
+ /**
130
+ * How often one recipient may enter this journey. A marketing journey
131
+ * enrolls each recipient once, ever; give it a `cooldown` and it re-opens
132
+ * that long after their last completed run. A transactional journey
133
+ * enrolls on every trigger — three orders are three receipts — so it
134
+ * takes no cooldown and `cow build` refuses one.
135
+ *
136
+ * History counts: adding a cooldown to a journey that has been running
137
+ * for months measures from runs that already completed.
138
+ */
139
+ enrollment?: {
140
+ cooldown: Duration;
141
+ };
142
+ /**
143
+ * One plain sentence saying what this journey does for the person who
144
+ * gets it. Shown wherever the journey is read. Optional; at most 500
145
+ * characters.
146
+ */
147
+ description?: string;
129
148
  /**
130
149
  * The address every `api.send.email` in this journey goes out as:
131
150
  * `"billing@acme.com"` or `"Billing <billing@acme.com>"`. A single send
132
151
  * can override it.
133
152
  *
134
- * Optional: leave it out and Cowliss sends from the address it gives your
135
- * organization, with nothing to set up. Named here rather than in a
136
- * settings page, so the address a journey sends from is readable in the
137
- * journey's own source.
153
+ * Required, on a domain your organization has verified: there is no
154
+ * address Cowliss lends you. Named here rather than in a settings page,
155
+ * so the address a journey sends from is readable in the journey's own
156
+ * source.
138
157
  */
139
- from?: string;
158
+ from: string;
140
159
  /** The author's labels; the dashboard's only grouping. Defaults to none. */
141
160
  tags: string[];
142
161
  };
@@ -147,11 +166,22 @@ type Journey = JourneyConfig & {
147
166
  * Author a journey. Throws at definition time on an invalid config.
148
167
  *
149
168
  * There is no environment gate here: an organization has one data space, and
150
- * a journey runs wherever it is enabled and nowhere else (ADR 0011, ADR 0013).
169
+ * a journey runs wherever it is on and nowhere else (ADR 0011, ADR 0013).
170
+ *
171
+ * There is no app gate either. A journey belongs to the one app `cow.json`
172
+ * names, so it only ever sees that app's events, profiles and segments
173
+ * (ADR 0022); a trigger naming an app is refused rather than honoured.
151
174
  */
152
- declare function defineJourney(input: Omit<JourneyConfig, "tags"> & {
175
+ declare function defineJourney(input: Omit<JourneyConfig, "tags" | "trigger"> & {
176
+ /**
177
+ * What starts it: `{ event }`, one event-name pattern or a list of
178
+ * them, or `{ segment }` carrying the predicate list of the segment
179
+ * this journey enrolls from. Written form, so a predicate can leave
180
+ * `match` and `atLeast` out. No app: the repository's app is the scope.
181
+ */
182
+ trigger: TriggerInput;
153
183
  tags?: string[];
154
184
  run: (event: Event, api: Api) => Promise<void>;
155
185
  }): Journey;
156
186
  //#endregion
157
- export { Api, CapabilityError, CowTemplates, type Duration, EmailSendResult, Event, Journey, JourneyConfig, Profile, type Trigger, defineJourney };
187
+ export { Api, CapabilityError, CowTemplates, type Duration, EmailSendResult, Event, Journey, JourneyConfig, Profile, type Trigger, type TriggerInput, defineJourney };
@@ -1,3 +1,3 @@
1
- import { n as defineJourney, t as CapabilityError } from "./journeys-BrcKEXz0.js";
1
+ import { n as defineJourney, t as CapabilityError } from "./journeys-F8Yk8s1P.js";
2
2
 
3
3
  export { CapabilityError, defineJourney };
@@ -1,4 +1,4 @@
1
- import { t as runGuest } from "./driver-DBt5l1Z5.js";
1
+ import { t as runGuest } from "./driver-BXuYP007.js";
2
2
 
3
3
  //#region src/guest/wasi.ts
4
4
  const STDIN = 0;