@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.
- package/README.md +1 -1
- package/dist/guest/{driver-DBt5l1Z5.js → driver-BXuYP007.js} +4 -1
- package/dist/guest/driver.d.ts +1 -1
- package/dist/guest/driver.js +1 -1
- package/dist/guest/emails.d.ts +6 -0
- package/dist/guest/{index-CXcCEcAg.d.ts → index-EqBCZnpq.d.ts} +106 -15
- package/dist/guest/{journeys-BrcKEXz0.js → journeys-F8Yk8s1P.js} +292 -64
- package/dist/guest/journeys.d.ts +44 -14
- package/dist/guest/journeys.js +1 -1
- package/dist/guest/wasi.js +1 -1
- package/dist/index.js +1275 -610
- package/examples/abandoned-checkout/journeys/abandoned-checkout.ts +9 -1
- package/examples/activity-decay/journeys/activity-decay.ts +13 -2
- package/examples/winback/journeys/winback.ts +10 -2
- package/package.json +1 -1
- package/examples/cross-app-pitch/emails/cross-app-pitch.tsx +0 -77
- package/examples/cross-app-pitch/journeys/cross-app-pitch.ts +0 -34
- package/examples/cross-app-pitch/scenarios/cross-app-pitch.json +0 -17
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
|
89
|
-
* `
|
|
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 `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
|
111
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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) => !
|
|
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
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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`
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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.
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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
|
|
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
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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:
|
|
559
|
+
orgId: orgIdSchema,
|
|
346
560
|
/**
|
|
347
|
-
* Which
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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
|
-
|
|
566
|
+
appId: appIdSchema,
|
|
352
567
|
/**
|
|
353
|
-
* The consent purposes this
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
698
|
-
|
|
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
|
}
|
package/dist/guest/journeys.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* the org declares it is checked when the
|
|
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
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
|
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
|
|
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 };
|
package/dist/guest/journeys.js
CHANGED
package/dist/guest/wasi.js
CHANGED