@cowliss/cli 0.6.0 → 0.7.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-C1bsCjZT.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-3PMqJKmc.d.ts} +104 -12
- package/dist/guest/{journeys-BrcKEXz0.js → journeys-Dpsp225V.js} +223 -43
- package/dist/guest/journeys.d.ts +37 -12
- package/dist/guest/journeys.js +1 -1
- package/dist/guest/wasi.js +1 -1
- package/dist/index.js +777 -290
- package/examples/abandoned-checkout/journeys/abandoned-checkout.ts +9 -1
- package/examples/activity-decay/journeys/activity-decay.ts +13 -2
- package/examples/cross-app-pitch/journeys/cross-app-pitch.ts +6 -1
- package/examples/winback/journeys/winback.ts +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ import { defineJourney } from "@cowliss/cli/journeys";
|
|
|
44
44
|
|
|
45
45
|
export default defineJourney({
|
|
46
46
|
trigger: { event: "checkout_started" },
|
|
47
|
-
purpose: "
|
|
47
|
+
purpose: "marketing",
|
|
48
48
|
run: async (event, api) => {
|
|
49
49
|
const purchased = await api.waitForEvent("purchase_completed", {
|
|
50
50
|
timeout: "24h",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as manifestOutputSchema, i as journeyStepOutputSchema, o as templateRenderOutputSchema, r as guestInputSchema, t as CapabilityError } from "./journeys-
|
|
1
|
+
import { a as manifestOutputSchema, i as journeyStepOutputSchema, o as templateRenderOutputSchema, r as guestInputSchema, t as CapabilityError } from "./journeys-Dpsp225V.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { renderToStaticMarkup } from "react-dom/server.browser";
|
|
@@ -5573,6 +5573,8 @@ function readManifest(module) {
|
|
|
5573
5573
|
kind: "journey",
|
|
5574
5574
|
trigger: journey.trigger,
|
|
5575
5575
|
purpose: journey.purpose,
|
|
5576
|
+
enrollment: journey.enrollment,
|
|
5577
|
+
description: journey.description,
|
|
5576
5578
|
from: journey.from,
|
|
5577
5579
|
tags: journey.tags
|
|
5578
5580
|
});
|
|
@@ -5583,6 +5585,7 @@ function readManifest(module) {
|
|
|
5583
5585
|
tags: template.tags,
|
|
5584
5586
|
sendClass: template.sendClass ?? "marketing",
|
|
5585
5587
|
verifyLink: template.verifyLink ?? false,
|
|
5588
|
+
unsubscribeLink: template.unsubscribeLink ?? false,
|
|
5586
5589
|
propsSchema: z.toJSONSchema(template.props, { target: "draft-2020-12" })
|
|
5587
5590
|
});
|
|
5588
5591
|
}
|
package/dist/guest/driver.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as JourneyStepOutput, o as ManifestOutput, s as TemplateRenderOutput } from "./index-3PMqJKmc.js";
|
|
2
2
|
//#region src/guest/driver.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* The in-process driver: JSON in, JSON out (spec: Guest protocol). One
|
package/dist/guest/driver.js
CHANGED
package/dist/guest/emails.d.ts
CHANGED
|
@@ -36,6 +36,12 @@ type Template<Schema extends z.ZodType = z.ZodType> = {
|
|
|
36
36
|
tags?: string[];
|
|
37
37
|
/** True asks the host to mint a signed `verifyUrl` prop at send time. */
|
|
38
38
|
verifyLink?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* True asks the host to mint a signed `unsubscribeUrl` prop at send time,
|
|
41
|
+
* for the author's own footer link. Leave it off and a marketing template
|
|
42
|
+
* still gets a platform footer with the link appended at send time.
|
|
43
|
+
*/
|
|
44
|
+
unsubscribeLink?: boolean;
|
|
39
45
|
};
|
|
40
46
|
//#endregion
|
|
41
47
|
export { type SendClass, Subject, Template };
|
|
@@ -6,9 +6,6 @@ import { z } from "zod";
|
|
|
6
6
|
* a guest returns with these schemas before anything acts on it; the guest
|
|
7
7
|
* SDK and the Node simulator produce and consume the same shapes.
|
|
8
8
|
*/
|
|
9
|
-
/** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
|
|
10
|
-
declare const durationSchema: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
|
|
11
|
-
type Duration = z.infer<typeof durationSchema>;
|
|
12
9
|
/** The event a journey runs for, or waits on: name, properties, and when. */
|
|
13
10
|
declare const guestEventSchema: z.ZodObject<{
|
|
14
11
|
name: z.ZodString;
|
|
@@ -257,9 +254,44 @@ declare const manifestOutputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
257
254
|
event: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
258
255
|
appId: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
259
256
|
}, z.core.$strict>, z.ZodObject<{
|
|
260
|
-
segment: z.
|
|
257
|
+
segment: z.ZodObject<{
|
|
258
|
+
match: z.ZodDefault<z.ZodEnum<{
|
|
259
|
+
all: "all";
|
|
260
|
+
any: "any";
|
|
261
|
+
}>>;
|
|
262
|
+
appId: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>>;
|
|
263
|
+
predicates: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
264
|
+
kind: z.ZodLiteral<"trait">;
|
|
265
|
+
name: z.ZodString;
|
|
266
|
+
op: z.ZodEnum<{
|
|
267
|
+
contains: "contains";
|
|
268
|
+
eq: "eq";
|
|
269
|
+
exists: "exists";
|
|
270
|
+
gt: "gt";
|
|
271
|
+
gte: "gte";
|
|
272
|
+
lt: "lt";
|
|
273
|
+
lte: "lte";
|
|
274
|
+
neq: "neq";
|
|
275
|
+
notExists: "notExists";
|
|
276
|
+
}>;
|
|
277
|
+
value: z.ZodOptional<z.ZodUnknown>;
|
|
278
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
279
|
+
kind: z.ZodLiteral<"event">;
|
|
280
|
+
name: z.ZodString;
|
|
281
|
+
op: z.ZodEnum<{
|
|
282
|
+
notPerformed: "notPerformed";
|
|
283
|
+
performed: "performed";
|
|
284
|
+
}>;
|
|
285
|
+
atLeast: z.ZodDefault<z.ZodNumber>;
|
|
286
|
+
withinDays: z.ZodOptional<z.ZodNumber>;
|
|
287
|
+
}, z.core.$strip>], "kind">>;
|
|
288
|
+
}, z.core.$strict>;
|
|
261
289
|
}, z.core.$strict>]>;
|
|
262
290
|
purpose: z.ZodString;
|
|
291
|
+
enrollment: z.ZodOptional<z.ZodObject<{
|
|
292
|
+
cooldown: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
|
|
293
|
+
}, z.core.$strict>>;
|
|
294
|
+
description: z.ZodOptional<z.ZodString>;
|
|
263
295
|
from: z.ZodOptional<z.ZodString>;
|
|
264
296
|
kind: z.ZodLiteral<"journey">;
|
|
265
297
|
}, z.core.$strip>, z.ZodObject<{
|
|
@@ -269,28 +301,88 @@ declare const manifestOutputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
269
301
|
transactional: "transactional";
|
|
270
302
|
}>;
|
|
271
303
|
verifyLink: z.ZodBoolean;
|
|
304
|
+
unsubscribeLink: z.ZodDefault<z.ZodBoolean>;
|
|
272
305
|
propsSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
273
306
|
kind: z.ZodLiteral<"template">;
|
|
274
307
|
}, z.core.$strip>], "kind">;
|
|
275
308
|
type ManifestOutput = z.infer<typeof manifestOutputSchema>;
|
|
276
309
|
//#endregion
|
|
277
310
|
//#region ../../packages/shared/src/journeys-v2/manifest.d.ts
|
|
311
|
+
/**
|
|
312
|
+
* The pushed manifest (spec: Build; Push and compile): what `cow build`
|
|
313
|
+
* extracts from a project and `cow push` uploads with the bundles. The
|
|
314
|
+
* server validates it with these schemas, compiles every bundle whose key
|
|
315
|
+
* changed, and stores that entry on the version it creates.
|
|
316
|
+
*/
|
|
317
|
+
/**
|
|
318
|
+
* A duration as journey code writes it: an ms-style string ("2d") or
|
|
319
|
+
* milliseconds. It lives here rather than with the guest protocol because a
|
|
320
|
+
* manifest carries one too (a journey's enrollment cooldown) and `./guest`
|
|
321
|
+
* already imports this module, so the other direction would be a cycle.
|
|
322
|
+
*/
|
|
323
|
+
declare const durationSchema: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
|
|
324
|
+
type Duration = z.infer<typeof durationSchema>;
|
|
278
325
|
/**
|
|
279
326
|
* What starts a journey: an event (optionally narrowed to an app id or a
|
|
280
|
-
* list of them) or
|
|
281
|
-
* reuses it.
|
|
327
|
+
* list of them), or entry into the segment the trigger itself describes.
|
|
328
|
+
* The registry DTO in `../journeys` reuses it.
|
|
329
|
+
*
|
|
330
|
+
* A segment trigger carries the predicate list, not a name: the push
|
|
331
|
+
* materializes one segment row per journey that inlines a definition, owned
|
|
332
|
+
* by the journey and named after its key, so the segment exists because the
|
|
333
|
+
* journey exists and there is no order to get wrong (ADR 0016). The
|
|
334
|
+
* definition is data — validated here, carried on the version, never
|
|
335
|
+
* compiled and never executed.
|
|
282
336
|
*
|
|
283
|
-
* Both members are strict, so a journey holding the retired `source` key
|
|
284
|
-
* fails to
|
|
285
|
-
*
|
|
286
|
-
* write is attributed to, the same
|
|
337
|
+
* Both members are strict, so a journey holding the retired `source` key, or
|
|
338
|
+
* the retired `{ segment: "name" }` form, fails to build instead of silently
|
|
339
|
+
* triggering on every app or on nothing. There is deliberately no pipe
|
|
340
|
+
* filter: a trigger narrows by the app the write is attributed to, the same
|
|
341
|
+
* token a segment definition names.
|
|
287
342
|
*/
|
|
288
343
|
declare const triggerSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
289
344
|
event: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
290
345
|
appId: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
291
346
|
}, z.core.$strict>, z.ZodObject<{
|
|
292
|
-
segment: z.
|
|
347
|
+
segment: z.ZodObject<{
|
|
348
|
+
match: z.ZodDefault<z.ZodEnum<{
|
|
349
|
+
all: "all";
|
|
350
|
+
any: "any";
|
|
351
|
+
}>>;
|
|
352
|
+
appId: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>>;
|
|
353
|
+
predicates: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
354
|
+
kind: z.ZodLiteral<"trait">;
|
|
355
|
+
name: z.ZodString;
|
|
356
|
+
op: z.ZodEnum<{
|
|
357
|
+
contains: "contains";
|
|
358
|
+
eq: "eq";
|
|
359
|
+
exists: "exists";
|
|
360
|
+
gt: "gt";
|
|
361
|
+
gte: "gte";
|
|
362
|
+
lt: "lt";
|
|
363
|
+
lte: "lte";
|
|
364
|
+
neq: "neq";
|
|
365
|
+
notExists: "notExists";
|
|
366
|
+
}>;
|
|
367
|
+
value: z.ZodOptional<z.ZodUnknown>;
|
|
368
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
369
|
+
kind: z.ZodLiteral<"event">;
|
|
370
|
+
name: z.ZodString;
|
|
371
|
+
op: z.ZodEnum<{
|
|
372
|
+
notPerformed: "notPerformed";
|
|
373
|
+
performed: "performed";
|
|
374
|
+
}>;
|
|
375
|
+
atLeast: z.ZodDefault<z.ZodNumber>;
|
|
376
|
+
withinDays: z.ZodOptional<z.ZodNumber>;
|
|
377
|
+
}, z.core.$strip>], "kind">>;
|
|
378
|
+
}, z.core.$strict>;
|
|
293
379
|
}, z.core.$strict>]>;
|
|
294
380
|
type Trigger = z.infer<typeof triggerSchema>;
|
|
381
|
+
/**
|
|
382
|
+
* A trigger as an author writes it, before zod fills in a predicate's
|
|
383
|
+
* `match` and `atLeast` defaults. `defineJourney` takes this shape;
|
|
384
|
+
* everything downstream reads the parsed `Trigger`.
|
|
385
|
+
*/
|
|
386
|
+
type TriggerInput = z.input<typeof triggerSchema>;
|
|
295
387
|
//#endregion
|
|
296
|
-
export {
|
|
388
|
+
export { JourneyStepOutput as a, GuestEvent as i, Trigger as n, ManifestOutput as o, TriggerInput as r, TemplateRenderOutput as s, Duration as t };
|
|
@@ -9,16 +9,24 @@ const DOCS_URL = "https://docs.cowliss.com";
|
|
|
9
9
|
*/
|
|
10
10
|
const CLERK_ORG_SUBJECT_PREFIX = "org_";
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* The marketing purpose by name, since it is the one every gate, the
|
|
13
|
+
* unsubscribe route, and the developer's own toggle all reach for.
|
|
14
|
+
*/
|
|
15
|
+
const MARKETING = "marketing";
|
|
16
|
+
/**
|
|
17
|
+
* A legal journey `purpose` that is not a consent purpose: the send gates
|
|
18
|
+
* pass it unconditionally, no profile's map stores it and no editor renders
|
|
19
|
+
* it. What protects the channel itself (suppression, the SES gates, the
|
|
20
|
+
* quota) still applies.
|
|
21
|
+
*/
|
|
22
|
+
const TRANSACTIONAL = "transactional";
|
|
23
|
+
/**
|
|
24
|
+
* The purposes a project may not declare, which are the same two that sit
|
|
25
|
+
* outside the `marketing` umbrella: `marketing` is the umbrella itself and
|
|
26
|
+
* `transactional` is the case consent does not govern. Everything a project
|
|
27
|
+
* declares is a marketing-mail category and so sits under it.
|
|
20
28
|
*/
|
|
21
|
-
const
|
|
29
|
+
const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
|
|
22
30
|
/**
|
|
23
31
|
* The two classes of send, declared on the template rather than passed per
|
|
24
32
|
* call so the class cannot drift between two sends of the same message.
|
|
@@ -56,6 +64,92 @@ const PUSH_LIMITS = {
|
|
|
56
64
|
const COW_CONFIG_SCHEMA_PATH = "/schemas/cow.json";
|
|
57
65
|
const COW_CONFIG_SCHEMA_URL = `${DOCS_URL}${COW_CONFIG_SCHEMA_PATH}`;
|
|
58
66
|
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region ../../packages/shared/src/segment-definition.ts
|
|
69
|
+
/**
|
|
70
|
+
* A segment definition: a flat list of predicates over traits and event
|
|
71
|
+
* history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
|
|
72
|
+
* prototype, and a flat list keeps the pure evaluator a fold.
|
|
73
|
+
*
|
|
74
|
+
* `appId` scopes the EVENT side of a definition only. Traits are
|
|
75
|
+
* per-profile and profiles merge across apps, so there is nothing
|
|
76
|
+
* app-shaped to filter on the trait side.
|
|
77
|
+
*
|
|
78
|
+
* A definition and the membership it produces are both the
|
|
79
|
+
* organization's.
|
|
80
|
+
*
|
|
81
|
+
* Apart from `./segments` because a journey's trigger carries a definition
|
|
82
|
+
* and the trigger schema is bundled into the wasm guest, where an edge to
|
|
83
|
+
* `@cowliss/db` (which `./segments` has, for the row schema) is a hard
|
|
84
|
+
* bundler failure. Nothing here imports anything but zod.
|
|
85
|
+
*
|
|
86
|
+
* The object is strict: a definition holding the retired `sourceId` key
|
|
87
|
+
* (which meant the app) fails loudly instead of parsing as an unfiltered
|
|
88
|
+
* definition that evaluates over every app. There is deliberately no pipe
|
|
89
|
+
* filter here — filtering by source is a later feature, and accepting one
|
|
90
|
+
* now would make a stale `sourceId` parse as a filter on a pipe that does
|
|
91
|
+
* not exist and silently match nothing.
|
|
92
|
+
*/
|
|
93
|
+
const SEGMENT_TRAIT_OPS = [
|
|
94
|
+
"eq",
|
|
95
|
+
"neq",
|
|
96
|
+
"gt",
|
|
97
|
+
"gte",
|
|
98
|
+
"lt",
|
|
99
|
+
"lte",
|
|
100
|
+
"exists",
|
|
101
|
+
"notExists",
|
|
102
|
+
"contains"
|
|
103
|
+
];
|
|
104
|
+
/** Operators that read no comparison value: presence of the key is the test. */
|
|
105
|
+
const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
|
|
106
|
+
const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
|
|
107
|
+
const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
|
|
108
|
+
kind: z.literal("trait"),
|
|
109
|
+
name: predicateNameSchema,
|
|
110
|
+
op: z.enum(SEGMENT_TRAIT_OPS),
|
|
111
|
+
/**
|
|
112
|
+
* Compared against the stored trait, which is `unknown` because
|
|
113
|
+
* identify accepts arbitrary JSON. The evaluator coerces both sides
|
|
114
|
+
* before comparing, so authors here (CLI, MCP, dashboard) get the
|
|
115
|
+
* form-field-friendly reading rather than strict JSON equality:
|
|
116
|
+
* numeric-looking strings are compared as numbers for eq/neq and for
|
|
117
|
+
* ordering ("150" matches 150, and orders like it), and "true"/"false"
|
|
118
|
+
* are compared as booleans for eq/neq, trimmed and case-insensitively
|
|
119
|
+
* (" TRUE " reads as true). Coercion needs both sides to agree on a
|
|
120
|
+
* type: "0" never equals false. The numeric net is as wide as
|
|
121
|
+
* `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
|
|
122
|
+
* matters most for opaque ids: "007" is authored as the number 7.
|
|
123
|
+
*
|
|
124
|
+
* contains reads three ways. Against an array trait it is membership
|
|
125
|
+
* under that same equality, so "true" matches `[true]` and "1" matches
|
|
126
|
+
* `[1, 2]`. Against a string trait it is a plain substring search with
|
|
127
|
+
* no coercion, since substrings only mean something between strings.
|
|
128
|
+
* Against anything else it never matches. Ordering never coerces
|
|
129
|
+
* booleans.
|
|
130
|
+
*/
|
|
131
|
+
value: z.unknown().optional()
|
|
132
|
+
}), z.object({
|
|
133
|
+
kind: z.literal("event"),
|
|
134
|
+
name: predicateNameSchema,
|
|
135
|
+
op: z.enum(["performed", "notPerformed"]),
|
|
136
|
+
/** How many matching events the predicate counts as "performed". */
|
|
137
|
+
atLeast: z.number().int().min(1).default(1),
|
|
138
|
+
/** Rolling window, relative to evaluation time; absent means all history. */
|
|
139
|
+
withinDays: z.number().int().min(1).optional()
|
|
140
|
+
})]).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" });
|
|
141
|
+
const appIdSchema = z.string().trim().min(1);
|
|
142
|
+
const segmentDefinitionSchema = z.strictObject({
|
|
143
|
+
match: z.enum(["all", "any"]).default("all"),
|
|
144
|
+
/**
|
|
145
|
+
* Optional app filter over the event side; null/absent spans apps.
|
|
146
|
+
* A list matches any of the named apps, which is how one definition
|
|
147
|
+
* names the development and the production id of the same app.
|
|
148
|
+
*/
|
|
149
|
+
appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
|
|
150
|
+
predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
|
|
151
|
+
});
|
|
152
|
+
|
|
59
153
|
//#endregion
|
|
60
154
|
//#region ../../packages/shared/src/journeys-v2/manifest.ts
|
|
61
155
|
/**
|
|
@@ -65,6 +159,34 @@ const COW_CONFIG_SCHEMA_URL = `${DOCS_URL}${COW_CONFIG_SCHEMA_PATH}`;
|
|
|
65
159
|
* changed, and stores that entry on the version it creates.
|
|
66
160
|
*/
|
|
67
161
|
/**
|
|
162
|
+
* A duration as journey code writes it: an ms-style string ("2d") or
|
|
163
|
+
* milliseconds. It lives here rather than with the guest protocol because a
|
|
164
|
+
* manifest carries one too (a journey's enrollment cooldown) and `./guest`
|
|
165
|
+
* already imports this module, so the other direction would be a cycle.
|
|
166
|
+
*/
|
|
167
|
+
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
168
|
+
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
|
|
169
|
+
const DURATION_UNIT_MS = {
|
|
170
|
+
ms: 1,
|
|
171
|
+
s: 1e3,
|
|
172
|
+
m: 6e4,
|
|
173
|
+
h: 36e5,
|
|
174
|
+
d: 864e5,
|
|
175
|
+
w: 6048e5
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* A duration in milliseconds. The simulator's virtual clock and the runner's
|
|
179
|
+
* timers both need it, and neither may pull in Temporal's `msToNumber` (one
|
|
180
|
+
* runs in the CLI, the other inside workflow code).
|
|
181
|
+
*/
|
|
182
|
+
function parseDuration(duration) {
|
|
183
|
+
if (typeof duration === "number") return duration;
|
|
184
|
+
const match = DURATION_PATTERN.exec(duration.trim());
|
|
185
|
+
if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
|
|
186
|
+
const unit = DURATION_UNIT_MS[match[2]];
|
|
187
|
+
return Math.round(Number(match[1]) * unit);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
68
190
|
* A journey or template key: the file basename under `journeys/` or
|
|
69
191
|
* `emails/`, kebab-case and unique across the project. Becomes part of the
|
|
70
192
|
* Temporal workflow id and travels in the journey chain, so it stays short.
|
|
@@ -85,8 +207,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
|
|
|
85
207
|
*/
|
|
86
208
|
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
209
|
/**
|
|
88
|
-
* A consent purpose key: camelCase, matching the
|
|
89
|
-
* `
|
|
210
|
+
* A consent purpose key: camelCase, matching the reserved `marketing` and
|
|
211
|
+
* `transactional`. Purposes are keys in the `consent` map a customer reads
|
|
90
212
|
* on their own profile, which is why they are not the kebab-case of a
|
|
91
213
|
* journey key.
|
|
92
214
|
*/
|
|
@@ -101,22 +223,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
|
|
|
101
223
|
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
224
|
/**
|
|
103
225
|
* One purpose a project declares in `cow.json` (spec: Decisions). A declared
|
|
104
|
-
* purpose is marketing-class and sits under the `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
226
|
+
* purpose is marketing-class and sits under the `marketing` umbrella, so
|
|
227
|
+
* `denied` is the only default it may carry: the purpose is absent on every
|
|
228
|
+
* profile that already exists, and a granted default would answer for all of
|
|
229
|
+
* them at once.
|
|
108
230
|
*
|
|
109
231
|
* 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.
|
|
232
|
+
* every stored manifest written before this still parses: this is a refusal
|
|
233
|
+
* at declaration time, not a narrower storage shape.
|
|
113
234
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
235
|
+
* Neither reserved purpose can be declared: `marketing` is the master switch
|
|
236
|
+
* every other project's journeys hang off, and `transactional` is not a
|
|
237
|
+
* consent purpose at all, so declaring it would promise a switch that no
|
|
238
|
+
* send ever reads.
|
|
117
239
|
*/
|
|
118
240
|
const declaredPurposeSchema = z.strictObject({
|
|
119
|
-
key: consentPurposeKeySchema.refine((key) => !
|
|
241
|
+
key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
|
|
120
242
|
/** What the dashboard and the account modal render beside the switch. */
|
|
121
243
|
label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
|
|
122
244
|
/** What the purpose means for a profile whose map does not answer it. */
|
|
@@ -138,18 +260,26 @@ const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 2
|
|
|
138
260
|
const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
|
|
139
261
|
/**
|
|
140
262
|
* What starts a journey: an event (optionally narrowed to an app id or a
|
|
141
|
-
* list of them) or
|
|
142
|
-
* reuses it.
|
|
263
|
+
* list of them), or entry into the segment the trigger itself describes.
|
|
264
|
+
* The registry DTO in `../journeys` reuses it.
|
|
265
|
+
*
|
|
266
|
+
* A segment trigger carries the predicate list, not a name: the push
|
|
267
|
+
* materializes one segment row per journey that inlines a definition, owned
|
|
268
|
+
* by the journey and named after its key, so the segment exists because the
|
|
269
|
+
* journey exists and there is no order to get wrong (ADR 0016). The
|
|
270
|
+
* definition is data — validated here, carried on the version, never
|
|
271
|
+
* compiled and never executed.
|
|
143
272
|
*
|
|
144
|
-
* Both members are strict, so a journey holding the retired `source` key
|
|
145
|
-
* fails to
|
|
146
|
-
*
|
|
147
|
-
* write is attributed to, the same
|
|
273
|
+
* Both members are strict, so a journey holding the retired `source` key, or
|
|
274
|
+
* the retired `{ segment: "name" }` form, fails to build instead of silently
|
|
275
|
+
* triggering on every app or on nothing. There is deliberately no pipe
|
|
276
|
+
* filter: a trigger narrows by the app the write is attributed to, the same
|
|
277
|
+
* token a segment definition names.
|
|
148
278
|
*/
|
|
149
279
|
const triggerSchema = z.union([z.strictObject({
|
|
150
280
|
event: patternSchema,
|
|
151
281
|
appId: patternSchema.optional()
|
|
152
|
-
}), z.strictObject({ segment:
|
|
282
|
+
}), z.strictObject({ segment: segmentDefinitionSchema })]);
|
|
153
283
|
/**
|
|
154
284
|
* The address half of a from-header: a local part, an `@`, and a dotted
|
|
155
285
|
* domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
|
|
@@ -252,10 +382,35 @@ const manifestJourneySchema = z.object({
|
|
|
252
382
|
*/
|
|
253
383
|
purpose: consentPurposeKeySchema,
|
|
254
384
|
/**
|
|
385
|
+
* How often one recipient may enter. Enrollment derives from the purpose
|
|
386
|
+
* (ADR 0015), so the only thing an author writes is how long after a
|
|
387
|
+
* completed run the journey re-opens: absent means once, ever. Refused on
|
|
388
|
+
* a transactional journey, which enrolls on every trigger — see
|
|
389
|
+
* `manifestSchema` below, where the cross-field check lives (a refinement
|
|
390
|
+
* on this object would break the `.pick()` the guest SDK and the guest
|
|
391
|
+
* protocol both take of it).
|
|
392
|
+
*/
|
|
393
|
+
enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
|
|
394
|
+
try {
|
|
395
|
+
parseDuration(value);
|
|
396
|
+
return true;
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
|
|
401
|
+
/**
|
|
402
|
+
* The author's own sentence about what this journey does, shown wherever
|
|
403
|
+
* the journey is read. Capped like a segment's description; absent when
|
|
404
|
+
* the author wrote none.
|
|
405
|
+
*/
|
|
406
|
+
description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
|
|
407
|
+
/**
|
|
255
408
|
* The address every `send.email` in this journey goes out as, unless the
|
|
256
|
-
* call names its own.
|
|
257
|
-
*
|
|
258
|
-
*
|
|
409
|
+
* call names its own. Required of the author (`defineJourney` types it so,
|
|
410
|
+
* and `cow build` refuses a journey without it), and still optional here:
|
|
411
|
+
* releases pushed before ADR 0014 was amended carry none, and this schema
|
|
412
|
+
* parses stored manifests as well as new ones. A push with no `from` is
|
|
413
|
+
* refused by the domain gate, which says what to do about it.
|
|
259
414
|
*/
|
|
260
415
|
from: fromSchema.optional(),
|
|
261
416
|
spine: z.array(spineEntrySchema),
|
|
@@ -268,6 +423,13 @@ const manifestTemplateSchema = z.object({
|
|
|
268
423
|
sendClass: z.enum(SEND_CLASSES),
|
|
269
424
|
/** True asks the host to mint a signed `verifyUrl` prop at send time. */
|
|
270
425
|
verifyLink: z.boolean(),
|
|
426
|
+
/**
|
|
427
|
+
* True asks the host to mint a signed `unsubscribeUrl` prop at send time,
|
|
428
|
+
* for the author's own footer link. Default false: a marketing send whose
|
|
429
|
+
* HTML carries no unsubscribe link at all still gets one, appended as a
|
|
430
|
+
* platform footer, so the link is never the author's to forget.
|
|
431
|
+
*/
|
|
432
|
+
unsubscribeLink: z.boolean().default(false),
|
|
271
433
|
/** JSON Schema of the template's `props`, converted by `cow build`. */
|
|
272
434
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
273
435
|
bundle: digestSchema
|
|
@@ -318,6 +480,15 @@ const manifestSchema = z.object({
|
|
|
318
480
|
uniqueKeys(manifest.journeys, ctx, "journeys");
|
|
319
481
|
uniqueKeys(manifest.templates, ctx, "templates");
|
|
320
482
|
uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
|
|
483
|
+
for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
|
|
484
|
+
code: "custom",
|
|
485
|
+
message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
|
|
486
|
+
path: [
|
|
487
|
+
"journeys",
|
|
488
|
+
index,
|
|
489
|
+
"enrollment"
|
|
490
|
+
]
|
|
491
|
+
});
|
|
321
492
|
});
|
|
322
493
|
/**
|
|
323
494
|
* One entry of a stored manifest: what a version is a snapshot of. Journeys
|
|
@@ -377,8 +548,6 @@ const cowConfigSchema = z.strictObject({
|
|
|
377
548
|
* a guest returns with these schemas before anything acts on it; the guest
|
|
378
549
|
* SDK and the Node simulator produce and consume the same shapes.
|
|
379
550
|
*/
|
|
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
551
|
/** The event a journey runs for, or waits on: name, properties, and when. */
|
|
383
552
|
const guestEventSchema = z.object({
|
|
384
553
|
name: z.string().min(1),
|
|
@@ -418,8 +587,9 @@ const commandSchema = z.discriminatedUnion("name", [
|
|
|
418
587
|
/**
|
|
419
588
|
* The address this mail leaves as. The guest SDK fills in the
|
|
420
589
|
* 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
|
|
590
|
+
* one resolution path and never reads the manifest to find it. A
|
|
591
|
+
* journey must name one, so absent on both is a release pushed before
|
|
592
|
+
* that was true, and the `domain` gate refuses it.
|
|
423
593
|
*/
|
|
424
594
|
from: fromSchema.optional(),
|
|
425
595
|
/** Where a reply to this one mail goes, instead of the from-address. */
|
|
@@ -563,11 +733,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
|
|
|
563
733
|
const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
|
|
564
734
|
trigger: true,
|
|
565
735
|
purpose: true,
|
|
736
|
+
enrollment: true,
|
|
737
|
+
description: true,
|
|
566
738
|
from: true,
|
|
567
739
|
tags: true
|
|
568
740
|
}).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
|
|
569
741
|
sendClass: true,
|
|
570
742
|
verifyLink: true,
|
|
743
|
+
unsubscribeLink: true,
|
|
571
744
|
propsSchema: true,
|
|
572
745
|
tags: true
|
|
573
746
|
}).extend({ kind: z.literal("template") })]);
|
|
@@ -683,6 +856,8 @@ var CapabilityError = class extends Error {
|
|
|
683
856
|
const journeyConfigSchema = manifestJourneySchema.pick({
|
|
684
857
|
trigger: true,
|
|
685
858
|
purpose: true,
|
|
859
|
+
enrollment: true,
|
|
860
|
+
description: true,
|
|
686
861
|
from: true,
|
|
687
862
|
tags: true
|
|
688
863
|
});
|
|
@@ -690,16 +865,21 @@ const journeyConfigSchema = manifestJourneySchema.pick({
|
|
|
690
865
|
* Author a journey. Throws at definition time on an invalid config.
|
|
691
866
|
*
|
|
692
867
|
* There is no environment gate here: an organization has one data space, and
|
|
693
|
-
* a journey runs wherever it is
|
|
868
|
+
* a journey runs wherever it is on and nowhere else (ADR 0011, ADR 0013).
|
|
694
869
|
*/
|
|
695
870
|
function defineJourney(input) {
|
|
871
|
+
const config = journeyConfigSchema.parse({
|
|
872
|
+
trigger: input.trigger,
|
|
873
|
+
purpose: input.purpose,
|
|
874
|
+
enrollment: input.enrollment,
|
|
875
|
+
description: input.description,
|
|
876
|
+
from: input.from,
|
|
877
|
+
tags: input.tags
|
|
878
|
+
});
|
|
879
|
+
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
880
|
return {
|
|
697
|
-
...
|
|
698
|
-
|
|
699
|
-
purpose: input.purpose,
|
|
700
|
-
from: input.from,
|
|
701
|
-
tags: input.tags
|
|
702
|
-
}),
|
|
881
|
+
...config,
|
|
882
|
+
from: config.from,
|
|
703
883
|
run: input.run
|
|
704
884
|
};
|
|
705
885
|
}
|