@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
package/dist/index.js
CHANGED
|
@@ -33,6 +33,34 @@ const DOCS_URL = "https://docs.cowliss.com";
|
|
|
33
33
|
*/
|
|
34
34
|
const REQUEST_ID_HEADER = "X-Request-Id";
|
|
35
35
|
/**
|
|
36
|
+
* Stripe-style ID prefixes per resource type, so ids in logs, URLs, and
|
|
37
|
+
* payloads are self-describing.
|
|
38
|
+
*/
|
|
39
|
+
const ID_PREFIXES = {
|
|
40
|
+
user: "usr_",
|
|
41
|
+
event: "evt_",
|
|
42
|
+
identifier: "idn_",
|
|
43
|
+
segment: "seg_",
|
|
44
|
+
/** The attribution unit: a readable `app_<slug>`, not a TypeID. */
|
|
45
|
+
app: "app_",
|
|
46
|
+
/** One inbound pipe into an app; a TypeID like everything else. */
|
|
47
|
+
source: "src_",
|
|
48
|
+
webhook: "whk_",
|
|
49
|
+
push: "psh_",
|
|
50
|
+
version: "ver_",
|
|
51
|
+
execution: "exe_",
|
|
52
|
+
delivery: "dlv_",
|
|
53
|
+
violation: "vio_",
|
|
54
|
+
quarantineEntry: "qtn_",
|
|
55
|
+
idempotencyKey: "idk_",
|
|
56
|
+
abuseEvent: "abu_",
|
|
57
|
+
suppression: "sup_",
|
|
58
|
+
senderDomain: "dom_",
|
|
59
|
+
journeyRun: "run_",
|
|
60
|
+
topup: "top_",
|
|
61
|
+
emailAddress: "eml_"
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
36
64
|
* Clerk ID prefix marking an organization-scoped subject. Ingestion API keys
|
|
37
65
|
* must resolve to an org subject; user-scoped keys are rejected.
|
|
38
66
|
*/
|
|
@@ -82,30 +110,30 @@ const TOPUP_PRESETS_MICROS = [
|
|
|
82
110
|
2e8
|
|
83
111
|
];
|
|
84
112
|
/**
|
|
85
|
-
* Fixed consent purposes for the prototype. Consent is a per-purpose map on
|
|
86
|
-
* the profile, checked at send-step execution time.
|
|
87
|
-
*
|
|
88
|
-
* The two names describe what the recipient agreed to, not the pipe it
|
|
89
|
-
* arrives on: "emails I did not ask for individually" and "my data leaving
|
|
90
|
-
* for somewhere else". Naming them after the channel (`email`, `webhook`)
|
|
91
|
-
* said nothing a recipient could consent to, and transactional mail already
|
|
92
|
-
* bypasses the email purpose, so it was only ever marketing consent.
|
|
93
|
-
*/
|
|
94
|
-
const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
|
|
95
|
-
/**
|
|
96
113
|
* The marketing purpose by name, since it is the one every gate, the
|
|
97
114
|
* unsubscribe route, and the developer's own toggle all reach for.
|
|
98
115
|
*/
|
|
99
|
-
const
|
|
116
|
+
const MARKETING = "marketing";
|
|
117
|
+
/**
|
|
118
|
+
* A legal journey `purpose` that is not a consent purpose: the send gates
|
|
119
|
+
* pass it unconditionally, no profile's map stores it and no editor renders
|
|
120
|
+
* it. What protects the channel itself (suppression, the SES gates, the
|
|
121
|
+
* quota) still applies.
|
|
122
|
+
*/
|
|
123
|
+
const TRANSACTIONAL = "transactional";
|
|
124
|
+
/**
|
|
125
|
+
* The purposes a project may not declare, which are the same two that sit
|
|
126
|
+
* outside the `marketing` umbrella: `marketing` is the umbrella itself and
|
|
127
|
+
* `transactional` is the case consent does not govern. Everything a project
|
|
128
|
+
* declares is a marketing-mail category and so sits under it.
|
|
129
|
+
*/
|
|
130
|
+
const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
|
|
100
131
|
/**
|
|
101
132
|
* What a purpose means when the profile's map does not answer it, matching
|
|
102
|
-
* the `profiles.consent` column default: marketing is asked for,
|
|
103
|
-
*
|
|
133
|
+
* the `profiles.consent` column default: marketing is asked for, never
|
|
134
|
+
* assumed.
|
|
104
135
|
*/
|
|
105
|
-
const CONSENT_PURPOSE_DEFAULTS = {
|
|
106
|
-
emailMarketing: false,
|
|
107
|
-
dataProcessing: true
|
|
108
|
-
};
|
|
136
|
+
const CONSENT_PURPOSE_DEFAULTS = { marketing: false };
|
|
109
137
|
/**
|
|
110
138
|
* The `defaults` argument `consentGranted` takes, built from an org's
|
|
111
139
|
* purpose rows. Every surface that renders or gates a purpose reads those
|
|
@@ -3143,7 +3171,7 @@ const bytea = customType({ dataType: () => "bytea" });
|
|
|
3143
3171
|
/**
|
|
3144
3172
|
* What an artifact is: a journey or template bundle as `cow build` emitted
|
|
3145
3173
|
* it, the wasm module the server compiled from it, or the gzipped source
|
|
3146
|
-
* tarball of the
|
|
3174
|
+
* tarball of the repo the push was built from.
|
|
3147
3175
|
*/
|
|
3148
3176
|
const artifactKindEnum = pgEnum("artifact_kind", [
|
|
3149
3177
|
"bundle",
|
|
@@ -3264,14 +3292,14 @@ const insertCatalogTraitSchema = createInsertSchema(catalogTraits);
|
|
|
3264
3292
|
//#region ../../packages/db/src/schema/consent-purposes.ts
|
|
3265
3293
|
/**
|
|
3266
3294
|
* The consent purposes an org's profiles answer: the two fixed ones, seeded
|
|
3267
|
-
* the first time the set is read, plus whatever the org's
|
|
3268
|
-
*
|
|
3295
|
+
* the first time the set is read, plus whatever the org's apps declare in
|
|
3296
|
+
* their `cow.json`. One read here returns the whole set, so nothing
|
|
3269
3297
|
* downstream unions a table with a constant.
|
|
3270
3298
|
*
|
|
3271
|
-
* Org-wide rather than per-
|
|
3272
|
-
*
|
|
3273
|
-
*
|
|
3274
|
-
*
|
|
3299
|
+
* Org-wide rather than per-app: `profiles.consent` is one jsonb map per
|
|
3300
|
+
* profile, so an answer given under one app is the same answer under the
|
|
3301
|
+
* next, and two apps declaring one key must agree on its label and default
|
|
3302
|
+
* or the deploy is refused.
|
|
3275
3303
|
*
|
|
3276
3304
|
* A deploy adds and updates rows and never deletes one (ADR 0009): profiles
|
|
3277
3305
|
* already hold answers against a purpose, and deleting it would orphan them.
|
|
@@ -3289,14 +3317,18 @@ const consentPurposes = pgTable("consent_purposes", {
|
|
|
3289
3317
|
*/
|
|
3290
3318
|
defaultGranted: boolean("default_granted").notNull(),
|
|
3291
3319
|
/**
|
|
3292
|
-
* The
|
|
3293
|
-
*
|
|
3294
|
-
*
|
|
3320
|
+
* The app whose `cow.json` declared it, and the one a disagreeing deploy
|
|
3321
|
+
* is refused in the name of. Null for the two fixed purposes, which every
|
|
3322
|
+
* org has and no app owns.
|
|
3295
3323
|
*/
|
|
3296
|
-
|
|
3324
|
+
appId: text("app_id"),
|
|
3297
3325
|
createdAt: createdAt(),
|
|
3298
3326
|
updatedAt: updatedAt()
|
|
3299
|
-
}, (table) => [primaryKey({ columns: [table.orgId, table.key] })
|
|
3327
|
+
}, (table) => [primaryKey({ columns: [table.orgId, table.key] }), foreignKey({
|
|
3328
|
+
columns: [table.orgId, table.appId],
|
|
3329
|
+
foreignColumns: [apps$1.orgId, apps$1.id],
|
|
3330
|
+
name: "consent_purposes_app_fk"
|
|
3331
|
+
})]);
|
|
3300
3332
|
|
|
3301
3333
|
//#endregion
|
|
3302
3334
|
//#region ../../packages/db/src/schema/deliveries.ts
|
|
@@ -3341,6 +3373,10 @@ const deliveryKindEnum = pgEnum("delivery_kind", [
|
|
|
3341
3373
|
* `skipped_suppressed` is the recipient's address sitting in the
|
|
3342
3374
|
* suppression mirror, which SES would have bounced off its own
|
|
3343
3375
|
* suppression list anyway.
|
|
3376
|
+
* `skipped_unknown_purpose` is the version naming a purpose the
|
|
3377
|
+
* organization does not have, which is a broken manifest rather than a
|
|
3378
|
+
* consent decision: the `error` column names the purpose, and the fix is
|
|
3379
|
+
* another push (ADR 0017).
|
|
3344
3380
|
* - `would_*` are dry-run outcomes: terminal, never reach SES, and
|
|
3345
3381
|
* excluded from feedback, quotas, and reconciliation.
|
|
3346
3382
|
*
|
|
@@ -3357,6 +3393,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
|
|
|
3357
3393
|
"skipped_paused",
|
|
3358
3394
|
"skipped_suppressed",
|
|
3359
3395
|
"skipped_quota",
|
|
3396
|
+
"skipped_unknown_purpose",
|
|
3360
3397
|
"skipped_frequency_cap",
|
|
3361
3398
|
"skipped_consent",
|
|
3362
3399
|
"skipped_domain",
|
|
@@ -3366,6 +3403,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
|
|
|
3366
3403
|
"would_skip_paused",
|
|
3367
3404
|
"would_skip_suppressed",
|
|
3368
3405
|
"would_skip_quota",
|
|
3406
|
+
"would_skip_unknown_purpose",
|
|
3369
3407
|
"would_skip_frequency_cap",
|
|
3370
3408
|
"would_skip_consent",
|
|
3371
3409
|
"would_skip_domain",
|
|
@@ -3386,6 +3424,15 @@ const deliveries$1 = pgTable("deliveries", {
|
|
|
3386
3424
|
* neither of which has one.
|
|
3387
3425
|
*/
|
|
3388
3426
|
journey: text("journey"),
|
|
3427
|
+
/**
|
|
3428
|
+
* The journey execution (`exe_`) this send was made from, so an execution
|
|
3429
|
+
* page can show what it sent. Null is a real state rather than a gap: a
|
|
3430
|
+
* test send and an api send have no execution behind them, and rows
|
|
3431
|
+
* written before this column existed have none to name. No foreign key,
|
|
3432
|
+
* because executions are pruned on their own schedule and a delivery is a
|
|
3433
|
+
* log row that outlives them.
|
|
3434
|
+
*/
|
|
3435
|
+
executionId: text("execution_id"),
|
|
3389
3436
|
kind: deliveryKindEnum("kind").notNull().default("journey"),
|
|
3390
3437
|
/**
|
|
3391
3438
|
* Email template name or webhook name. An api send that named no
|
|
@@ -3443,6 +3490,7 @@ const deliveries$1 = pgTable("deliveries", {
|
|
|
3443
3490
|
}, (table) => [
|
|
3444
3491
|
index("deliveries_org_id_created_at_idx").on(table.orgId, table.createdAt),
|
|
3445
3492
|
index("deliveries_org_id_profile_id_created_at_idx").on(table.orgId, table.profileId, table.createdAt),
|
|
3493
|
+
index("deliveries_org_id_execution_id_created_at_idx").on(table.orgId, table.executionId, table.createdAt),
|
|
3446
3494
|
uniqueIndex("deliveries_org_id_attempt_key_unique").on(table.orgId, table.attemptKey),
|
|
3447
3495
|
uniqueIndex("deliveries_provider_id_unique").on(table.providerId),
|
|
3448
3496
|
index("deliveries_status_sent_at_idx").on(table.status, table.sentAt)
|
|
@@ -3551,7 +3599,7 @@ const executions$1 = pgTable("executions", {
|
|
|
3551
3599
|
})
|
|
3552
3600
|
}, (table) => [
|
|
3553
3601
|
index("executions_org_id_version_id_status_idx").on(table.orgId, table.versionId, table.status),
|
|
3554
|
-
|
|
3602
|
+
index("executions_org_id_workflow_id_status_idx").on(table.orgId, table.workflowId, table.status),
|
|
3555
3603
|
index("executions_org_id_started_at_idx").on(table.orgId, table.startedAt)
|
|
3556
3604
|
]);
|
|
3557
3605
|
const selectExecutionSchema = createSelectSchema(executions$1);
|
|
@@ -3619,11 +3667,11 @@ const idempotencyKeys = pgTable("idempotency_keys", {
|
|
|
3619
3667
|
* creating one when none is known.
|
|
3620
3668
|
*
|
|
3621
3669
|
* traits is the merged trait bag (RFC 7386 key-level merge on write).
|
|
3622
|
-
* consent is the
|
|
3623
|
-
*
|
|
3624
|
-
*
|
|
3625
|
-
*
|
|
3626
|
-
*
|
|
3670
|
+
* consent is the per-purpose consent map. Marketing starts denied: nobody
|
|
3671
|
+
* is subscribed by the act of being ingested. `transactional` is absent by
|
|
3672
|
+
* design (ADR 0017): it is not a purpose anyone may withhold, so it is not
|
|
3673
|
+
* stored. The map is written by the identify path (a caller passing
|
|
3674
|
+
* `consent`), the consent editor, and the automatic revocations.
|
|
3627
3675
|
*
|
|
3628
3676
|
* `mergedInto` is the merge pointer (spec: Identity). Null on a live
|
|
3629
3677
|
* profile; on a profile a merge folded away it names the survivor, whose
|
|
@@ -3641,10 +3689,7 @@ const profiles = pgTable("profiles", {
|
|
|
3641
3689
|
appId: text("app_id").notNull(),
|
|
3642
3690
|
sourceId: text("source_id").notNull(),
|
|
3643
3691
|
traits: jsonb("traits").$type().notNull().default({}),
|
|
3644
|
-
consent: jsonb("consent").$type().notNull().default({
|
|
3645
|
-
emailMarketing: false,
|
|
3646
|
-
dataProcessing: true
|
|
3647
|
-
}),
|
|
3692
|
+
consent: jsonb("consent").$type().notNull().default({ marketing: false }),
|
|
3648
3693
|
mergedInto: text("merged_into"),
|
|
3649
3694
|
createdAt: createdAt(),
|
|
3650
3695
|
updatedAt: updatedAt()
|
|
@@ -3685,10 +3730,10 @@ const identifierKindEnum = pgEnum("identifier_kind", [
|
|
|
3685
3730
|
]);
|
|
3686
3731
|
/**
|
|
3687
3732
|
* The identifiers map: every named identifier a call has ever carried,
|
|
3688
|
-
* pointing at the profile it resolved to. Unique per (org, kind, value),
|
|
3689
|
-
* which is what makes "a shared identifier means the same person"
|
|
3690
|
-
*
|
|
3691
|
-
* is
|
|
3733
|
+
* pointing at the profile it resolved to. Unique per (org, app, kind, value),
|
|
3734
|
+
* which is what makes "a shared identifier means the same person" enforceable
|
|
3735
|
+
* at the row level, inside one app: the same clerkId twice in one app is one
|
|
3736
|
+
* person, and the same email on two apps is two people (ADR 0022).
|
|
3692
3737
|
*
|
|
3693
3738
|
* Rows follow their profile: erasure deletes the profile and these cascade,
|
|
3694
3739
|
* and a merge re-points them at the survivor.
|
|
@@ -3696,11 +3741,21 @@ const identifierKindEnum = pgEnum("identifier_kind", [
|
|
|
3696
3741
|
const identifiers = pgTable("identifiers", {
|
|
3697
3742
|
id: text("id").primaryKey(),
|
|
3698
3743
|
orgId: text("org_id").notNull(),
|
|
3744
|
+
/** The app this identifier resolves in; always its profile's app. */
|
|
3745
|
+
appId: text("app_id").notNull(),
|
|
3699
3746
|
kind: identifierKindEnum("kind").notNull(),
|
|
3700
3747
|
value: text("value").notNull(),
|
|
3701
3748
|
profileId: text("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
|
|
3702
3749
|
createdAt: createdAt()
|
|
3703
|
-
}, (table) => [
|
|
3750
|
+
}, (table) => [
|
|
3751
|
+
uniqueIndex("identifiers_org_app_kind_value_unique").on(table.orgId, table.appId, table.kind, table.value),
|
|
3752
|
+
index("identifiers_profile_id_idx").on(table.profileId),
|
|
3753
|
+
foreignKey({
|
|
3754
|
+
columns: [table.orgId, table.appId],
|
|
3755
|
+
foreignColumns: [apps$1.orgId, apps$1.id],
|
|
3756
|
+
name: "identifiers_app_fk"
|
|
3757
|
+
})
|
|
3758
|
+
]);
|
|
3704
3759
|
const selectIdentifierSchema = createSelectSchema(identifiers);
|
|
3705
3760
|
const insertIdentifierSchema = createInsertSchema(identifiers);
|
|
3706
3761
|
|
|
@@ -3745,6 +3800,18 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
|
|
|
3745
3800
|
//#endregion
|
|
3746
3801
|
//#region ../../packages/db/src/schema/journeys.ts
|
|
3747
3802
|
/**
|
|
3803
|
+
* The one operational state a manager sets (ADR 0018): `on` (recipients
|
|
3804
|
+
* enroll and executions run), `off` (nothing enrolls, executions in flight
|
|
3805
|
+
* finish) or `paused` (nothing enrolls, executions in flight hold before
|
|
3806
|
+
* their next step). Three values rather than two booleans: a second flag
|
|
3807
|
+
* beside the first would spell two combinations that mean nothing.
|
|
3808
|
+
*/
|
|
3809
|
+
const journeyStatusEnum = pgEnum("journey_status", [
|
|
3810
|
+
"on",
|
|
3811
|
+
"off",
|
|
3812
|
+
"paused"
|
|
3813
|
+
]);
|
|
3814
|
+
/**
|
|
3748
3815
|
* Derived journey rows: one per (org, key), upserted from the manifest a
|
|
3749
3816
|
* push carried. Nothing here is authored through the API, which is why
|
|
3750
3817
|
* there is no id of its own: the key is the name the author gave the file,
|
|
@@ -3752,14 +3819,15 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
|
|
|
3752
3819
|
*
|
|
3753
3820
|
* A push only ever adds and updates: it owns the keys its manifest carries
|
|
3754
3821
|
* and leaves every other row alone, because an org's journeys can come from
|
|
3755
|
-
* several
|
|
3756
|
-
*
|
|
3757
|
-
*
|
|
3758
|
-
*
|
|
3822
|
+
* several apps and a developer may push a repo holding only some of the
|
|
3823
|
+
* files. `appId` records which app put the row here, which is what makes a
|
|
3824
|
+
* key another app owns a refused push rather than a silent overwrite
|
|
3825
|
+
* (ADR 0009, held by the app since ADR 0022). Removing a journey is an
|
|
3826
|
+
* explicit delete.
|
|
3759
3827
|
*
|
|
3760
|
-
* Whether a journey fires is one
|
|
3828
|
+
* Whether a journey fires is one field and one only: `status`, which a
|
|
3761
3829
|
* push never writes, so deploying code never turns anything on or off. The
|
|
3762
|
-
* author has no second gate of their own (ADR 0011).
|
|
3830
|
+
* author has no second gate of their own (ADR 0011, ADR 0018).
|
|
3763
3831
|
*
|
|
3764
3832
|
* The descriptive columns are what its latest ready version reported, so a
|
|
3765
3833
|
* failed compile leaves both the code and its description as they were.
|
|
@@ -3769,28 +3837,38 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
|
|
|
3769
3837
|
const journeys$1 = pgTable("journeys", {
|
|
3770
3838
|
orgId: text("org_id").notNull(),
|
|
3771
3839
|
key: text("key").notNull(),
|
|
3772
|
-
/** The
|
|
3773
|
-
|
|
3840
|
+
/** The app whose push owns this key, and the only app it fires for. */
|
|
3841
|
+
appId: text("app_id").notNull(),
|
|
3774
3842
|
/** The author's labels, from the manifest; the dashboard's only grouping. */
|
|
3775
3843
|
tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
|
|
3776
3844
|
trigger: jsonb("trigger").$type().notNull(),
|
|
3777
3845
|
/**
|
|
3778
3846
|
* The consent purpose this journey's sends are gated on: one of the two
|
|
3779
|
-
* fixed keys or one the
|
|
3847
|
+
* fixed keys or one the app's repo declares in `cow.json`. Text and not an
|
|
3780
3848
|
* enum, because the set is the org's `consent_purposes` rows, which a
|
|
3781
3849
|
* deploy checks the key against; a database enum could only ever hold
|
|
3782
3850
|
* the fixed pair.
|
|
3783
3851
|
*/
|
|
3784
3852
|
purpose: text("purpose").notNull(),
|
|
3853
|
+
/**
|
|
3854
|
+
* The author's own sentence about what the journey does, from the
|
|
3855
|
+
* manifest. Null when they wrote none.
|
|
3856
|
+
*/
|
|
3857
|
+
description: text("description"),
|
|
3785
3858
|
spine: jsonb("spine").$type().notNull(),
|
|
3786
3859
|
/**
|
|
3787
3860
|
* The one gate on the journey: off until `cow enable` or a manager's
|
|
3788
|
-
*
|
|
3861
|
+
* control, and never touched by a push (ADR 0011, amended by ADR 0013
|
|
3862
|
+
* and ADR 0018).
|
|
3789
3863
|
*/
|
|
3790
|
-
|
|
3864
|
+
status: journeyStatusEnum("status").notNull().default("off"),
|
|
3791
3865
|
createdAt: createdAt(),
|
|
3792
3866
|
updatedAt: updatedAt()
|
|
3793
|
-
}, (table) => [primaryKey({ columns: [table.orgId, table.key] })
|
|
3867
|
+
}, (table) => [primaryKey({ columns: [table.orgId, table.key] }), foreignKey({
|
|
3868
|
+
columns: [table.orgId, table.appId],
|
|
3869
|
+
foreignColumns: [apps$1.orgId, apps$1.id],
|
|
3870
|
+
name: "journeys_app_fk"
|
|
3871
|
+
})]);
|
|
3794
3872
|
const selectJourneySchema = createSelectSchema(journeys$1);
|
|
3795
3873
|
const insertJourneySchema = createInsertSchema(journeys$1);
|
|
3796
3874
|
|
|
@@ -3814,17 +3892,16 @@ const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"])
|
|
|
3814
3892
|
* switch, the allowance override, and Clerk's cached profile.
|
|
3815
3893
|
*
|
|
3816
3894
|
* `slug` and `name` are the exception to "only what Cowliss owns": they are
|
|
3817
|
-
* Clerk's, cached here by the API's org-sync middleware
|
|
3818
|
-
*
|
|
3819
|
-
* `<org-slug>@<shared domain>` with the org's name as the from-name.
|
|
3895
|
+
* Clerk's, cached here by the API's org-sync middleware, and they are what a
|
|
3896
|
+
* template test-send composes its from-address out of (`orgFromAddress`).
|
|
3820
3897
|
*/
|
|
3821
3898
|
const orgSettings = pgTable("org_settings", {
|
|
3822
3899
|
orgId: text("org_id").primaryKey(),
|
|
3823
3900
|
/**
|
|
3824
3901
|
* The Clerk org's slug and display name, synced by the API on request.
|
|
3825
|
-
* Null until a member of the org has hit the dashboard API once
|
|
3826
|
-
*
|
|
3827
|
-
*
|
|
3902
|
+
* Null until a member of the org has hit the dashboard API once, when
|
|
3903
|
+
* `orgFromAddress` falls back to an orgId-derived local part and the tool
|
|
3904
|
+
* name.
|
|
3828
3905
|
*/
|
|
3829
3906
|
slug: text("slug"),
|
|
3830
3907
|
name: text("name"),
|
|
@@ -3891,33 +3968,15 @@ const orgSettings = pgTable("org_settings", {
|
|
|
3891
3968
|
const selectOrgSettingsSchema = createSelectSchema(orgSettings);
|
|
3892
3969
|
const insertOrgSettingsSchema = createInsertSchema(orgSettings);
|
|
3893
3970
|
|
|
3894
|
-
//#endregion
|
|
3895
|
-
//#region ../../packages/db/src/schema/projects.ts
|
|
3896
|
-
/**
|
|
3897
|
-
* An org's cow project: the folder a developer runs `cow init` in, and the
|
|
3898
|
-
* thing pushes belong to. An org may hold several, one per repo, each with
|
|
3899
|
-
* its own release sequence and its own slice of the deployed journey rows. The name is how `cow.json` picks
|
|
3900
|
-
* one, so it is unique within the org.
|
|
3901
|
-
*/
|
|
3902
|
-
const projects = pgTable("projects", {
|
|
3903
|
-
id: text("id").primaryKey(),
|
|
3904
|
-
orgId: text("org_id").notNull(),
|
|
3905
|
-
name: text("name").notNull(),
|
|
3906
|
-
createdAt: createdAt(),
|
|
3907
|
-
updatedAt: updatedAt()
|
|
3908
|
-
}, (table) => [uniqueIndex("projects_org_id_name_unique").on(table.orgId, table.name)]);
|
|
3909
|
-
const selectProjectSchema = createSelectSchema(projects);
|
|
3910
|
-
const insertProjectSchema = createInsertSchema(projects);
|
|
3911
|
-
|
|
3912
3971
|
//#endregion
|
|
3913
3972
|
//#region ../../packages/db/src/schema/pushes.ts
|
|
3914
3973
|
/**
|
|
3915
|
-
* One `cow push`: the whole
|
|
3974
|
+
* One `cow push`: the whole repo at one point in time. A push stores the
|
|
3916
3975
|
* source archive and creates a version of every key whose bundle changed
|
|
3917
3976
|
* (ADR 0011); it settles nothing and changes no flag, so it has no status
|
|
3918
3977
|
* of its own. What is compiling is read off its versions.
|
|
3919
3978
|
*
|
|
3920
|
-
* `seq` is the human handle and is per
|
|
3979
|
+
* `seq` is the human handle and is per app, allocated inside the create
|
|
3921
3980
|
* transaction; the unique index leads with the org id so it doubles as the
|
|
3922
3981
|
* tenancy key.
|
|
3923
3982
|
*
|
|
@@ -3928,7 +3987,8 @@ const insertProjectSchema = createInsertSchema(projects);
|
|
|
3928
3987
|
const pushes$1 = pgTable("pushes", {
|
|
3929
3988
|
id: text("id").primaryKey(),
|
|
3930
3989
|
orgId: text("org_id").notNull(),
|
|
3931
|
-
|
|
3990
|
+
/** The app this push deployed; the push unit since ADR 0022. */
|
|
3991
|
+
appId: text("app_id").notNull(),
|
|
3932
3992
|
seq: integer("seq").notNull(),
|
|
3933
3993
|
manifest: jsonb("manifest").$type().notNull(),
|
|
3934
3994
|
/** sha256 of the pushed manifest; the CLI compares it to skip a no-op push. */
|
|
@@ -3937,7 +3997,15 @@ const pushes$1 = pgTable("pushes", {
|
|
|
3937
3997
|
sourceDigest: text("source_digest").notNull(),
|
|
3938
3998
|
createdBy: text("created_by").notNull(),
|
|
3939
3999
|
createdAt: createdAt()
|
|
3940
|
-
}, (table) => [
|
|
4000
|
+
}, (table) => [
|
|
4001
|
+
uniqueIndex("pushes_org_id_app_id_seq_unique").on(table.orgId, table.appId, table.seq),
|
|
4002
|
+
index("pushes_org_id_created_at_idx").on(table.orgId, table.createdAt),
|
|
4003
|
+
foreignKey({
|
|
4004
|
+
columns: [table.orgId, table.appId],
|
|
4005
|
+
foreignColumns: [apps$1.orgId, apps$1.id],
|
|
4006
|
+
name: "pushes_app_fk"
|
|
4007
|
+
})
|
|
4008
|
+
]);
|
|
3941
4009
|
const selectPushSchema = createSelectSchema(pushes$1);
|
|
3942
4010
|
const insertPushSchema = createInsertSchema(pushes$1);
|
|
3943
4011
|
/**
|
|
@@ -4038,9 +4106,20 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
|
|
|
4038
4106
|
//#endregion
|
|
4039
4107
|
//#region ../../packages/db/src/schema/segments.ts
|
|
4040
4108
|
/**
|
|
4041
|
-
* Segments:
|
|
4042
|
-
*
|
|
4043
|
-
*
|
|
4109
|
+
* Segments: membership rules over profiles. Membership is computed, never
|
|
4110
|
+
* hand-maintained: the engine in packages/segments recomputes on every write
|
|
4111
|
+
* and materializes the result into segment_members.
|
|
4112
|
+
*
|
|
4113
|
+
* A segment belongs to one app (ADR 0022) and sees only that app's profiles
|
|
4114
|
+
* and that app's events.
|
|
4115
|
+
*
|
|
4116
|
+
* A row is either standalone (built in the dashboard, `journey_key` null) or
|
|
4117
|
+
* owned by one journey whose trigger inlined its definition (ADR 0016). An
|
|
4118
|
+
* owned row is named after its journey's key, is written only by the push,
|
|
4119
|
+
* and goes when the journey does — which is what the composite foreign key
|
|
4120
|
+
* says: it points at `journeys(org_id, key)` and cascades. A null
|
|
4121
|
+
* `journey_key` satisfies it vacuously (MATCH SIMPLE), so standalone rows
|
|
4122
|
+
* are unconstrained.
|
|
4044
4123
|
*
|
|
4045
4124
|
* Timestamps use millisecond precision, same rationale as apps: JS Dates
|
|
4046
4125
|
* carry ms only and cursor pagination compares createdAt for equality.
|
|
@@ -4048,12 +4127,29 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
|
|
|
4048
4127
|
const segments$1 = pgTable("segments", {
|
|
4049
4128
|
id: text("id").primaryKey(),
|
|
4050
4129
|
orgId: text("org_id").notNull(),
|
|
4130
|
+
/** The one app this segment evaluates. */
|
|
4131
|
+
appId: text("app_id").notNull(),
|
|
4051
4132
|
name: text("name").notNull(),
|
|
4052
4133
|
description: text("description"),
|
|
4053
4134
|
definition: jsonb("definition").$type().notNull(),
|
|
4135
|
+
/** The journey that owns this row, or null for a standalone segment. */
|
|
4136
|
+
journeyKey: text("journey_key"),
|
|
4054
4137
|
createdAt: createdAt(),
|
|
4055
4138
|
updatedAt: updatedAt()
|
|
4056
|
-
}, (table) => [
|
|
4139
|
+
}, (table) => [
|
|
4140
|
+
index("segments_org_id_idx").on(table.orgId),
|
|
4141
|
+
uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name),
|
|
4142
|
+
foreignKey({
|
|
4143
|
+
columns: [table.orgId, table.journeyKey],
|
|
4144
|
+
foreignColumns: [journeys$1.orgId, journeys$1.key],
|
|
4145
|
+
name: "segments_journey_fk"
|
|
4146
|
+
}).onDelete("cascade"),
|
|
4147
|
+
foreignKey({
|
|
4148
|
+
columns: [table.orgId, table.appId],
|
|
4149
|
+
foreignColumns: [apps$1.orgId, apps$1.id],
|
|
4150
|
+
name: "segments_app_fk"
|
|
4151
|
+
})
|
|
4152
|
+
]);
|
|
4057
4153
|
/**
|
|
4058
4154
|
* Materialized membership: one row per (segment, profile) currently in the
|
|
4059
4155
|
* segment. Deleting a segment drops its members with it, and erasing a
|
|
@@ -4089,10 +4185,16 @@ const insertSegmentSchema = createInsertSchema(segments$1);
|
|
|
4089
4185
|
* emits no domain events, so the mirror refreshes by pulling
|
|
4090
4186
|
* `GetEmailIdentity` on a schedule and on the manual re-check button.
|
|
4091
4187
|
*
|
|
4092
|
-
*
|
|
4093
|
-
*
|
|
4094
|
-
*
|
|
4095
|
-
*
|
|
4188
|
+
* A domain is claimed when it VERIFIES, not when it is added (ADR 0021).
|
|
4189
|
+
* Any number of orgs may hold the same unverified string; the partial unique
|
|
4190
|
+
* below is what makes at most one of them verified, and so the owner. The
|
|
4191
|
+
* cross-org parent/child guard (a query, not a constraint) refuses a claim
|
|
4192
|
+
* under or over a name another org already verified.
|
|
4193
|
+
*
|
|
4194
|
+
* SES keys ONE identity by the domain name, so its DKIM records, and the
|
|
4195
|
+
* SUCCESS it reports for them, are shared by every org holding the name.
|
|
4196
|
+
* They therefore prove that SOMEBODY controls the DNS, never which org, and
|
|
4197
|
+
* `challenge_token` is the part that is per org: the claim needs both.
|
|
4096
4198
|
*/
|
|
4097
4199
|
/**
|
|
4098
4200
|
* SES's identity status vocabulary, lower-cased like every other enum here.
|
|
@@ -4100,13 +4202,19 @@ const insertSegmentSchema = createInsertSchema(segments$1);
|
|
|
4100
4202
|
* all report the same five words. `temporary_failure` is SES's retryable
|
|
4101
4203
|
* state, which is neither verified nor a dead end, so it is kept distinct
|
|
4102
4204
|
* from `failed`.
|
|
4205
|
+
*
|
|
4206
|
+
* `blocked` is the one value SES never says: the DNS checks out, but another
|
|
4207
|
+
* organization has already verified this name or a parent or child of it, so
|
|
4208
|
+
* the claim cannot be granted here (ADR 0021). It is a terminal-looking
|
|
4209
|
+
* state that clears itself, because every refresh re-asks the question.
|
|
4103
4210
|
*/
|
|
4104
4211
|
const senderDomainStatusEnum = pgEnum("sender_domain_status", [
|
|
4105
4212
|
"not_started",
|
|
4106
4213
|
"pending",
|
|
4107
4214
|
"success",
|
|
4108
4215
|
"failed",
|
|
4109
|
-
"temporary_failure"
|
|
4216
|
+
"temporary_failure",
|
|
4217
|
+
"blocked"
|
|
4110
4218
|
]);
|
|
4111
4219
|
const senderDomains = pgTable("sender_domains", {
|
|
4112
4220
|
id: text("id").primaryKey(),
|
|
@@ -4120,12 +4228,25 @@ const senderDomains = pgTable("sender_domains", {
|
|
|
4120
4228
|
spfStatus: text("spf_status"),
|
|
4121
4229
|
/**
|
|
4122
4230
|
* The per-domain click-tracking toggle, Cowliss's own setting: the send
|
|
4123
|
-
* picks SES's tracked configuration set when it is on. Off by default
|
|
4124
|
-
*
|
|
4125
|
-
* at all): one setting cannot serve every org sharing it.
|
|
4231
|
+
* picks SES's tracked configuration set when it is on. Off by default:
|
|
4232
|
+
* link rewriting is a thing an org opts into.
|
|
4126
4233
|
*/
|
|
4127
4234
|
clickTracking: boolean("click_tracking").notNull().default(false),
|
|
4128
4235
|
dnsRecords: jsonb("dns_records").$type().notNull().default([]),
|
|
4236
|
+
/**
|
|
4237
|
+
* This org's own proof of control, published as a TXT record under the
|
|
4238
|
+
* domain. Random per row and never re-issued, so two orgs holding the
|
|
4239
|
+
* same name publish different values and only the one that controls the
|
|
4240
|
+
* DNS can publish its own. Verified once, at the moment the claim is
|
|
4241
|
+
* granted; a live sender never loses its domain to one failed lookup.
|
|
4242
|
+
*/
|
|
4243
|
+
challengeToken: text("challenge_token").notNull().default(sql`replace(gen_random_uuid()::text, '-', '')`),
|
|
4244
|
+
/** When this org's own TXT record was last seen. Null until it is. */
|
|
4245
|
+
challengeVerifiedAt: timestamp("challenge_verified_at", {
|
|
4246
|
+
withTimezone: true,
|
|
4247
|
+
mode: "date",
|
|
4248
|
+
precision: 3
|
|
4249
|
+
}),
|
|
4129
4250
|
/** When the mirror last asked SES; drives the refresh sweep. */
|
|
4130
4251
|
lastCheckedAt: timestamp("last_checked_at", {
|
|
4131
4252
|
withTimezone: true,
|
|
@@ -4134,7 +4255,11 @@ const senderDomains = pgTable("sender_domains", {
|
|
|
4134
4255
|
}),
|
|
4135
4256
|
createdAt: createdAt(),
|
|
4136
4257
|
updatedAt: updatedAt()
|
|
4137
|
-
}, (table) => [
|
|
4258
|
+
}, (table) => [
|
|
4259
|
+
uniqueIndex("sender_domains_org_id_domain_unique").on(table.orgId, table.domain),
|
|
4260
|
+
uniqueIndex("sender_domains_verified_domain_unique").on(table.domain).where(sql`${table.status} = 'success'`),
|
|
4261
|
+
index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)
|
|
4262
|
+
]);
|
|
4138
4263
|
const selectSenderDomainSchema = createSelectSchema(senderDomains);
|
|
4139
4264
|
const insertSenderDomainSchema = createInsertSchema(senderDomains);
|
|
4140
4265
|
|
|
@@ -4286,8 +4411,8 @@ const versions$1 = pgTable("versions", {
|
|
|
4286
4411
|
/** The file basename its author wrote; unique per org within a kind. */
|
|
4287
4412
|
key: text("key").notNull(),
|
|
4288
4413
|
kind: versionKindEnum("kind").notNull(),
|
|
4289
|
-
/** The
|
|
4290
|
-
|
|
4414
|
+
/** The app that pushed this key; ADR 0009's ownership, ADR 0022's app. */
|
|
4415
|
+
appId: text("app_id").notNull(),
|
|
4291
4416
|
/** The push that compiled this version's module. */
|
|
4292
4417
|
pushId: text("push_id").notNull().references(() => pushes$1.id),
|
|
4293
4418
|
moduleDigest: text("module_digest"),
|
|
@@ -4307,7 +4432,12 @@ const versions$1 = pgTable("versions", {
|
|
|
4307
4432
|
}, (table) => [
|
|
4308
4433
|
uniqueIndex("versions_org_id_kind_key_module_digest_unique").on(table.orgId, table.kind, table.key, table.moduleDigest),
|
|
4309
4434
|
index("versions_org_id_kind_key_pushed_at_idx").on(table.orgId, table.kind, table.key, table.pushedAt),
|
|
4310
|
-
index("versions_org_id_push_id_idx").on(table.orgId, table.pushId)
|
|
4435
|
+
index("versions_org_id_push_id_idx").on(table.orgId, table.pushId),
|
|
4436
|
+
foreignKey({
|
|
4437
|
+
columns: [table.orgId, table.appId],
|
|
4438
|
+
foreignColumns: [apps$1.orgId, apps$1.id],
|
|
4439
|
+
name: "versions_app_fk"
|
|
4440
|
+
})
|
|
4311
4441
|
]);
|
|
4312
4442
|
const selectVersionSchema = createSelectSchema(versions$1);
|
|
4313
4443
|
const insertVersionSchema = createInsertSchema(versions$1);
|
|
@@ -4490,8 +4620,6 @@ const ERROR_CODE_STATUS = {
|
|
|
4490
4620
|
journey_nondeterministic: 500,
|
|
4491
4621
|
push_invalid: 422,
|
|
4492
4622
|
version_not_ready: 409,
|
|
4493
|
-
project_exists: 409,
|
|
4494
|
-
project_missing: 404,
|
|
4495
4623
|
artifact_too_large: 413
|
|
4496
4624
|
};
|
|
4497
4625
|
/** First zod issue's message: enough signal for a 422 without a novel format. */
|
|
@@ -4545,6 +4673,21 @@ const identifierValueSchema = z.string().trim().min(1, "an identifier value is r
|
|
|
4545
4673
|
* and a call names only the kinds it has.
|
|
4546
4674
|
*/
|
|
4547
4675
|
const identifiersSchema = z.partialRecord(identifierKindSchema, identifierValueSchema).refine((value) => Object.keys(value).length > 0, { message: "identifiers must carry at least one entry" });
|
|
4676
|
+
/**
|
|
4677
|
+
* One identifier named as an object: how compliance addresses a person
|
|
4678
|
+
* when no `usr_` id is at hand (ADR 0022). A value two apps know is two
|
|
4679
|
+
* people, so the routes taking this act on every one of them.
|
|
4680
|
+
*
|
|
4681
|
+
* An object rather than the lookup route's `<kind>:<value>` string, because
|
|
4682
|
+
* the kind is then an enum at the boundary (a typo'd kind is a 422 for
|
|
4683
|
+
* free) and an address needs no escaping inside it. The value is
|
|
4684
|
+
* normalized by `normalizeIdentifierValue` where it is read, like every
|
|
4685
|
+
* other identifier.
|
|
4686
|
+
*/
|
|
4687
|
+
const identifierRefSchema = z.object({
|
|
4688
|
+
kind: identifierKindSchema,
|
|
4689
|
+
value: identifierValueSchema
|
|
4690
|
+
});
|
|
4548
4691
|
/** One identifier as the profile DTOs carry it. */
|
|
4549
4692
|
const identifierDtoSchema = z.object({
|
|
4550
4693
|
kind: identifierKindSchema,
|
|
@@ -4552,6 +4695,82 @@ const identifierDtoSchema = z.object({
|
|
|
4552
4695
|
createdAt: z.iso.datetime()
|
|
4553
4696
|
});
|
|
4554
4697
|
|
|
4698
|
+
//#endregion
|
|
4699
|
+
//#region ../../packages/shared/src/segment-definition.ts
|
|
4700
|
+
/**
|
|
4701
|
+
* A segment definition: a flat list of predicates over traits and event
|
|
4702
|
+
* history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
|
|
4703
|
+
* prototype, and a flat list keeps the pure evaluator a fold.
|
|
4704
|
+
*
|
|
4705
|
+
* A definition names no app (ADR 0022): the segment row's own `app_id` is
|
|
4706
|
+
* the one app it evaluates, so the events and the profiles it reads are
|
|
4707
|
+
* that app's and there is nothing to filter by here.
|
|
4708
|
+
*
|
|
4709
|
+
* Apart from `./segments` because a journey's trigger carries a definition
|
|
4710
|
+
* and the trigger schema is bundled into the wasm guest, where an edge to
|
|
4711
|
+
* `@cowliss/db` (which `./segments` has, for the row schema) is a hard
|
|
4712
|
+
* bundler failure. Nothing here imports anything but zod.
|
|
4713
|
+
*
|
|
4714
|
+
* The object is strict: a definition holding the retired `sourceId` or
|
|
4715
|
+
* `appId` key (both of which meant the app) fails loudly instead of parsing
|
|
4716
|
+
* as an unfiltered definition that evaluates over every app. There is deliberately no pipe
|
|
4717
|
+
* filter here — filtering by source is a later feature, and accepting one
|
|
4718
|
+
* now would make a stale `sourceId` parse as a filter on a pipe that does
|
|
4719
|
+
* not exist and silently match nothing.
|
|
4720
|
+
*/
|
|
4721
|
+
const SEGMENT_TRAIT_OPS = [
|
|
4722
|
+
"eq",
|
|
4723
|
+
"neq",
|
|
4724
|
+
"gt",
|
|
4725
|
+
"gte",
|
|
4726
|
+
"lt",
|
|
4727
|
+
"lte",
|
|
4728
|
+
"exists",
|
|
4729
|
+
"notExists",
|
|
4730
|
+
"contains"
|
|
4731
|
+
];
|
|
4732
|
+
/** Operators that read no comparison value: presence of the key is the test. */
|
|
4733
|
+
const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
|
|
4734
|
+
const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
|
|
4735
|
+
const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
|
|
4736
|
+
kind: z.literal("trait"),
|
|
4737
|
+
name: predicateNameSchema,
|
|
4738
|
+
op: z.enum(SEGMENT_TRAIT_OPS),
|
|
4739
|
+
/**
|
|
4740
|
+
* Compared against the stored trait, which is `unknown` because
|
|
4741
|
+
* identify accepts arbitrary JSON. The evaluator coerces both sides
|
|
4742
|
+
* before comparing, so authors here (CLI, MCP, dashboard) get the
|
|
4743
|
+
* form-field-friendly reading rather than strict JSON equality:
|
|
4744
|
+
* numeric-looking strings are compared as numbers for eq/neq and for
|
|
4745
|
+
* ordering ("150" matches 150, and orders like it), and "true"/"false"
|
|
4746
|
+
* are compared as booleans for eq/neq, trimmed and case-insensitively
|
|
4747
|
+
* (" TRUE " reads as true). Coercion needs both sides to agree on a
|
|
4748
|
+
* type: "0" never equals false. The numeric net is as wide as
|
|
4749
|
+
* `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
|
|
4750
|
+
* matters most for opaque ids: "007" is authored as the number 7.
|
|
4751
|
+
*
|
|
4752
|
+
* contains reads three ways. Against an array trait it is membership
|
|
4753
|
+
* under that same equality, so "true" matches `[true]` and "1" matches
|
|
4754
|
+
* `[1, 2]`. Against a string trait it is a plain substring search with
|
|
4755
|
+
* no coercion, since substrings only mean something between strings.
|
|
4756
|
+
* Against anything else it never matches. Ordering never coerces
|
|
4757
|
+
* booleans.
|
|
4758
|
+
*/
|
|
4759
|
+
value: z.unknown().optional()
|
|
4760
|
+
}), z.object({
|
|
4761
|
+
kind: z.literal("event"),
|
|
4762
|
+
name: predicateNameSchema,
|
|
4763
|
+
op: z.enum(["performed", "notPerformed"]),
|
|
4764
|
+
/** How many matching events the predicate counts as "performed". */
|
|
4765
|
+
atLeast: z.number().int().min(1).default(1),
|
|
4766
|
+
/** Rolling window, relative to evaluation time; absent means all history. */
|
|
4767
|
+
withinDays: z.number().int().min(1).optional()
|
|
4768
|
+
})]).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" });
|
|
4769
|
+
const segmentDefinitionSchema = z.strictObject({
|
|
4770
|
+
match: z.enum(["all", "any"]).default("all"),
|
|
4771
|
+
predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
|
|
4772
|
+
});
|
|
4773
|
+
|
|
4555
4774
|
//#endregion
|
|
4556
4775
|
//#region ../../packages/shared/src/journeys-v2/manifest.ts
|
|
4557
4776
|
/**
|
|
@@ -4561,6 +4780,34 @@ const identifierDtoSchema = z.object({
|
|
|
4561
4780
|
* changed, and stores that entry on the version it creates.
|
|
4562
4781
|
*/
|
|
4563
4782
|
/**
|
|
4783
|
+
* A duration as journey code writes it: an ms-style string ("2d") or
|
|
4784
|
+
* milliseconds. It lives here rather than with the guest protocol because a
|
|
4785
|
+
* manifest carries one too (a journey's enrollment cooldown) and `./guest`
|
|
4786
|
+
* already imports this module, so the other direction would be a cycle.
|
|
4787
|
+
*/
|
|
4788
|
+
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
4789
|
+
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
|
|
4790
|
+
const DURATION_UNIT_MS = {
|
|
4791
|
+
ms: 1,
|
|
4792
|
+
s: 1e3,
|
|
4793
|
+
m: 6e4,
|
|
4794
|
+
h: 36e5,
|
|
4795
|
+
d: 864e5,
|
|
4796
|
+
w: 6048e5
|
|
4797
|
+
};
|
|
4798
|
+
/**
|
|
4799
|
+
* A duration in milliseconds. The simulator's virtual clock and the runner's
|
|
4800
|
+
* timers both need it, and neither may pull in Temporal's `msToNumber` (one
|
|
4801
|
+
* runs in the CLI, the other inside workflow code).
|
|
4802
|
+
*/
|
|
4803
|
+
function parseDuration(duration) {
|
|
4804
|
+
if (typeof duration === "number") return duration;
|
|
4805
|
+
const match = DURATION_PATTERN.exec(duration.trim());
|
|
4806
|
+
if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
|
|
4807
|
+
const unit = DURATION_UNIT_MS[match[2]];
|
|
4808
|
+
return Math.round(Number(match[1]) * unit);
|
|
4809
|
+
}
|
|
4810
|
+
/**
|
|
4564
4811
|
* A journey or template key: the file basename under `journeys/` or
|
|
4565
4812
|
* `emails/`, kebab-case and unique across the project. Becomes part of the
|
|
4566
4813
|
* Temporal workflow id and travels in the journey chain, so it stays short.
|
|
@@ -4581,8 +4828,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
|
|
|
4581
4828
|
*/
|
|
4582
4829
|
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)]);
|
|
4583
4830
|
/**
|
|
4584
|
-
* A consent purpose key: camelCase, matching the
|
|
4585
|
-
* `
|
|
4831
|
+
* A consent purpose key: camelCase, matching the reserved `marketing` and
|
|
4832
|
+
* `transactional`. Purposes are keys in the `consent` map a customer reads
|
|
4586
4833
|
* on their own profile, which is why they are not the kebab-case of a
|
|
4587
4834
|
* journey key.
|
|
4588
4835
|
*/
|
|
@@ -4597,22 +4844,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
|
|
|
4597
4844
|
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)");
|
|
4598
4845
|
/**
|
|
4599
4846
|
* One purpose a project declares in `cow.json` (spec: Decisions). A declared
|
|
4600
|
-
* purpose is marketing-class and sits under the `
|
|
4601
|
-
*
|
|
4602
|
-
*
|
|
4603
|
-
*
|
|
4847
|
+
* purpose is marketing-class and sits under the `marketing` umbrella, so
|
|
4848
|
+
* `denied` is the only default it may carry: the purpose is absent on every
|
|
4849
|
+
* profile that already exists, and a granted default would answer for all of
|
|
4850
|
+
* them at once.
|
|
4604
4851
|
*
|
|
4605
4852
|
* The field stays required rather than disappearing, so every `cow.json` and
|
|
4606
|
-
* every stored manifest written before this still parses
|
|
4607
|
-
*
|
|
4608
|
-
* is a refusal at declaration time, not a narrower storage shape.
|
|
4853
|
+
* every stored manifest written before this still parses: this is a refusal
|
|
4854
|
+
* at declaration time, not a narrower storage shape.
|
|
4609
4855
|
*
|
|
4610
|
-
*
|
|
4611
|
-
*
|
|
4612
|
-
*
|
|
4856
|
+
* Neither reserved purpose can be declared: `marketing` is the master switch
|
|
4857
|
+
* every other project's journeys hang off, and `transactional` is not a
|
|
4858
|
+
* consent purpose at all, so declaring it would promise a switch that no
|
|
4859
|
+
* send ever reads.
|
|
4613
4860
|
*/
|
|
4614
4861
|
const declaredPurposeSchema = z.strictObject({
|
|
4615
|
-
key: consentPurposeKeySchema.refine((key) => !
|
|
4862
|
+
key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
|
|
4616
4863
|
/** What the dashboard and the account modal render beside the switch. */
|
|
4617
4864
|
label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
|
|
4618
4865
|
/** What the purpose means for a profile whose map does not answer it. */
|
|
@@ -4633,19 +4880,27 @@ const purposesSchema = z.array(declaredPurposeSchema).max(20, "a project declare
|
|
|
4633
4880
|
const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 200 characters");
|
|
4634
4881
|
const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
|
|
4635
4882
|
/**
|
|
4636
|
-
* What starts a journey: an event
|
|
4637
|
-
*
|
|
4638
|
-
*
|
|
4883
|
+
* What starts a journey: an event, or entry into the segment the trigger
|
|
4884
|
+
* itself describes. The registry DTO in `../journeys` reuses it.
|
|
4885
|
+
*
|
|
4886
|
+
* No trigger names an app (ADR 0022): a repository is one app, the journey
|
|
4887
|
+
* row carries its `app_id`, and the runtime compares that with the event's.
|
|
4888
|
+
* An `appId` here would be a second way to say it, and the way to leak.
|
|
4639
4889
|
*
|
|
4640
|
-
*
|
|
4641
|
-
*
|
|
4642
|
-
*
|
|
4643
|
-
*
|
|
4890
|
+
* A segment trigger carries the predicate list, not a name: the push
|
|
4891
|
+
* materializes one segment row per journey that inlines a definition, owned
|
|
4892
|
+
* by the journey and named after its key, so the segment exists because the
|
|
4893
|
+
* journey exists and there is no order to get wrong (ADR 0016). The
|
|
4894
|
+
* definition is data — validated here, carried on the version, never
|
|
4895
|
+
* compiled and never executed.
|
|
4896
|
+
*
|
|
4897
|
+
* Both members are strict, so a journey holding the retired `source` or
|
|
4898
|
+
* `appId` key, or the retired `{ segment: "name" }` form, fails to build
|
|
4899
|
+
* instead of silently triggering on every app or on nothing. There is
|
|
4900
|
+
* deliberately no pipe filter and no app filter: the journey's own app is
|
|
4901
|
+
* the only narrowing there is.
|
|
4644
4902
|
*/
|
|
4645
|
-
const triggerSchema = z.union([z.strictObject({
|
|
4646
|
-
event: patternSchema,
|
|
4647
|
-
appId: patternSchema.optional()
|
|
4648
|
-
}), z.strictObject({ segment: z.string().min(1) })]);
|
|
4903
|
+
const triggerSchema = z.union([z.strictObject({ event: patternSchema }), z.strictObject({ segment: segmentDefinitionSchema })]);
|
|
4649
4904
|
/**
|
|
4650
4905
|
* The address half of a from-header: a local part, an `@`, and a dotted
|
|
4651
4906
|
* domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
|
|
@@ -4748,10 +5003,35 @@ const manifestJourneySchema = z.object({
|
|
|
4748
5003
|
*/
|
|
4749
5004
|
purpose: consentPurposeKeySchema,
|
|
4750
5005
|
/**
|
|
5006
|
+
* How often one recipient may enter. Enrollment derives from the purpose
|
|
5007
|
+
* (ADR 0015), so the only thing an author writes is how long after a
|
|
5008
|
+
* completed run the journey re-opens: absent means once, ever. Refused on
|
|
5009
|
+
* a transactional journey, which enrolls on every trigger — see
|
|
5010
|
+
* `manifestSchema` below, where the cross-field check lives (a refinement
|
|
5011
|
+
* on this object would break the `.pick()` the guest SDK and the guest
|
|
5012
|
+
* protocol both take of it).
|
|
5013
|
+
*/
|
|
5014
|
+
enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
|
|
5015
|
+
try {
|
|
5016
|
+
parseDuration(value);
|
|
5017
|
+
return true;
|
|
5018
|
+
} catch {
|
|
5019
|
+
return false;
|
|
5020
|
+
}
|
|
5021
|
+
}, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
|
|
5022
|
+
/**
|
|
5023
|
+
* The author's own sentence about what this journey does, shown wherever
|
|
5024
|
+
* the journey is read. Capped like a segment's description; absent when
|
|
5025
|
+
* the author wrote none.
|
|
5026
|
+
*/
|
|
5027
|
+
description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
|
|
5028
|
+
/**
|
|
4751
5029
|
* The address every `send.email` in this journey goes out as, unless the
|
|
4752
|
-
* call names its own.
|
|
4753
|
-
*
|
|
4754
|
-
*
|
|
5030
|
+
* call names its own. Required of the author (`defineJourney` types it so,
|
|
5031
|
+
* and `cow build` refuses a journey without it), and still optional here:
|
|
5032
|
+
* releases pushed before ADR 0014 was amended carry none, and this schema
|
|
5033
|
+
* parses stored manifests as well as new ones. A push with no `from` is
|
|
5034
|
+
* refused by the domain gate, which says what to do about it.
|
|
4755
5035
|
*/
|
|
4756
5036
|
from: fromSchema.optional(),
|
|
4757
5037
|
spine: z.array(spineEntrySchema),
|
|
@@ -4764,6 +5044,13 @@ const manifestTemplateSchema = z.object({
|
|
|
4764
5044
|
sendClass: z.enum(SEND_CLASSES),
|
|
4765
5045
|
/** True asks the host to mint a signed `verifyUrl` prop at send time. */
|
|
4766
5046
|
verifyLink: z.boolean(),
|
|
5047
|
+
/**
|
|
5048
|
+
* True asks the host to mint a signed `unsubscribeUrl` prop at send time,
|
|
5049
|
+
* for the author's own footer link. Default false: a marketing send whose
|
|
5050
|
+
* HTML carries no unsubscribe link at all still gets one, appended as a
|
|
5051
|
+
* platform footer, so the link is never the author's to forget.
|
|
5052
|
+
*/
|
|
5053
|
+
unsubscribeLink: z.boolean().default(false),
|
|
4767
5054
|
/** JSON Schema of the template's `props`, converted by `cow build`. */
|
|
4768
5055
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
4769
5056
|
bundle: digestSchema
|
|
@@ -4814,6 +5101,15 @@ const manifestSchema = z.object({
|
|
|
4814
5101
|
uniqueKeys(manifest.journeys, ctx, "journeys");
|
|
4815
5102
|
uniqueKeys(manifest.templates, ctx, "templates");
|
|
4816
5103
|
uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
|
|
5104
|
+
for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
|
|
5105
|
+
code: "custom",
|
|
5106
|
+
message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
|
|
5107
|
+
path: [
|
|
5108
|
+
"journeys",
|
|
5109
|
+
index,
|
|
5110
|
+
"enrollment"
|
|
5111
|
+
]
|
|
5112
|
+
});
|
|
4817
5113
|
});
|
|
4818
5114
|
/**
|
|
4819
5115
|
* One entry of a stored manifest: what a version is a snapshot of. Journeys
|
|
@@ -4877,7 +5173,7 @@ const traitBagSchema = z.custom((value) => value !== null && typeof value === "o
|
|
|
4877
5173
|
* `identify` carries it too: the grant has to have a route in through
|
|
4878
5174
|
* ingestion, or the only thing anyone can express is the revocation.
|
|
4879
5175
|
*/
|
|
4880
|
-
const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [
|
|
5176
|
+
const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
|
|
4881
5177
|
/**
|
|
4882
5178
|
* The identify payload fields, shared between the single-call body schema
|
|
4883
5179
|
* and the batch item schema (which drops `sourceId`: a batch names one
|
|
@@ -4900,6 +5196,74 @@ const identifyDataSchema = identifyFieldsSchema.refine((data) => notFutureTimest
|
|
|
4900
5196
|
});
|
|
4901
5197
|
const identifyBodySchema = z.object({ data: identifyDataSchema });
|
|
4902
5198
|
|
|
5199
|
+
//#endregion
|
|
5200
|
+
//#region ../../packages/shared/src/journeys-v2/config.ts
|
|
5201
|
+
/**
|
|
5202
|
+
* An app id as `cow.json` carries it: the readable `app_<slug>`
|
|
5203
|
+
* `appIdFromName` derives, which is the one id a developer types by hand.
|
|
5204
|
+
*
|
|
5205
|
+
* It lives here rather than beside the app DTOs in ../apps because that
|
|
5206
|
+
* module imports the Drizzle schema, which neither the wasmtime guest nor
|
|
5207
|
+
* the Temporal workflow isolate can carry, and because `cow.json` is the
|
|
5208
|
+
* file a developer types it into. Everything that names an app on the wire
|
|
5209
|
+
* reuses it.
|
|
5210
|
+
*/
|
|
5211
|
+
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\"");
|
|
5212
|
+
/**
|
|
5213
|
+
* A source id: the one inbound pipe an ingestion or send call names. It
|
|
5214
|
+
* lives beside `appIdSchema` for the same two reasons — the sources module
|
|
5215
|
+
* imports the Drizzle schema, and both are ids that travel on the wire — and
|
|
5216
|
+
* it has the same shape, so a bare `src_` is refused here rather than at the
|
|
5217
|
+
* database.
|
|
5218
|
+
*
|
|
5219
|
+
* The prefixes above and here are interpolated raw; `journeys-v2.test.ts`
|
|
5220
|
+
* asserts they are plain `<word>_` literals, so none of them can quietly
|
|
5221
|
+
* change what these patterns match.
|
|
5222
|
+
*/
|
|
5223
|
+
const sourceIdSchema = z.string().trim().max(200).regex(new RegExp(`^${ID_PREFIXES.source}[a-z0-9_]+$`), "a source id looks like \"src_01j8x…\"");
|
|
5224
|
+
/**
|
|
5225
|
+
* A Clerk organization id, as `cow.json` spells it. Exported beside
|
|
5226
|
+
* `appIdSchema` because the push body carries the org the file names so the
|
|
5227
|
+
* server can refuse a push aimed at another one (ADR 0022).
|
|
5228
|
+
*/
|
|
5229
|
+
const orgIdSchema = z.string().trim().max(200).startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id");
|
|
5230
|
+
/**
|
|
5231
|
+
* `cow.json` (spec: Project layout): the org, and which of its apps this
|
|
5232
|
+
* repository pushes to. Auth never lives in the file. Strict, so a typo'd
|
|
5233
|
+
* key is a build error rather than a silently ignored setting — which is
|
|
5234
|
+
* also what turns the retired `project` key into a refusal (`cow build`
|
|
5235
|
+
* says what to rename it to).
|
|
5236
|
+
*/
|
|
5237
|
+
const cowConfigSchema = z.strictObject({
|
|
5238
|
+
$schema: z.url().optional(),
|
|
5239
|
+
orgId: orgIdSchema,
|
|
5240
|
+
/**
|
|
5241
|
+
* Which app of the org this repository pushes to (ADR 0022). The app is
|
|
5242
|
+
* the push unit: it owns the journey and template keys, and `cow
|
|
5243
|
+
* status`, `cow enable --all` and `cow pull` all resolve against it. One
|
|
5244
|
+
* repository is one app, so no journey file names one.
|
|
5245
|
+
*/
|
|
5246
|
+
appId: appIdSchema,
|
|
5247
|
+
/**
|
|
5248
|
+
* The consent purposes this repository declares. They are org-wide, so
|
|
5249
|
+
* two apps declaring one key must agree on its label and default or the
|
|
5250
|
+
* push is refused; a push adds and updates them and never deletes one,
|
|
5251
|
+
* because profiles hold answers against them.
|
|
5252
|
+
*/
|
|
5253
|
+
purposes: purposesSchema.optional(),
|
|
5254
|
+
/** Overrides the API the CLI talks to; the hosted product needs none. */
|
|
5255
|
+
apiUrl: z.url().optional(),
|
|
5256
|
+
/**
|
|
5257
|
+
* Overrides the dashboard `cow login` opens. Paired with `apiUrl`: a
|
|
5258
|
+
* login against one Cowliss's dashboard yields a token the other's API
|
|
5259
|
+
* rejects, so a config that names an API names its dashboard too.
|
|
5260
|
+
*/
|
|
5261
|
+
webUrl: z.url().optional()
|
|
5262
|
+
}).meta({
|
|
5263
|
+
title: "cow.json",
|
|
5264
|
+
description: "A cow project: the organization and the app it pushes to."
|
|
5265
|
+
});
|
|
5266
|
+
|
|
4903
5267
|
//#endregion
|
|
4904
5268
|
//#region ../../packages/shared/src/track.ts
|
|
4905
5269
|
/**
|
|
@@ -4959,7 +5323,7 @@ const trackBodySchema = z.object({ data: trackDataSchema });
|
|
|
4959
5323
|
*/
|
|
4960
5324
|
const listEventsQuerySchema = paginationQuerySchema.extend({
|
|
4961
5325
|
direction: sortDirectionSchema.default("desc"),
|
|
4962
|
-
appId:
|
|
5326
|
+
appId: appIdSchema.optional(),
|
|
4963
5327
|
sourceId: z.string().trim().min(1).max(200).optional(),
|
|
4964
5328
|
event: z.string().trim().min(1).max(200).optional(),
|
|
4965
5329
|
/**
|
|
@@ -5482,10 +5846,16 @@ const listDeliveriesQuerySchema = paginationQuerySchema.extend({
|
|
|
5482
5846
|
channel: deliveryChannelSchema.optional(),
|
|
5483
5847
|
/** Why the send happened: a journey, a test send, or a direct API send. */
|
|
5484
5848
|
kind: deliveryKindSchema.optional(),
|
|
5485
|
-
appId:
|
|
5849
|
+
appId: appIdSchema.optional(),
|
|
5486
5850
|
/** One person's deliveries, merged ids included, same as the event feed. */
|
|
5487
5851
|
profileId: z.string().trim().min(1).max(200).optional(),
|
|
5488
5852
|
/**
|
|
5853
|
+
* What one journey execution sent. Exact, not approximated from journey
|
|
5854
|
+
* and profile: a profile that enrolls twice has one execution per
|
|
5855
|
+
* enrollment, and the older one must not show the newer one's sends.
|
|
5856
|
+
*/
|
|
5857
|
+
executionId: z.string().trim().min(1).max(200).optional(),
|
|
5858
|
+
/**
|
|
5489
5859
|
* Only deliveries touched at or after this instant, by `updatedAt`: the
|
|
5490
5860
|
* cursor a tail (`cow dev`) polls with, so a send and its later feedback
|
|
5491
5861
|
* transition both arrive. Inclusive for the same reason as the execution
|
|
@@ -5523,6 +5893,7 @@ const dnsRecordSchema = z.object({
|
|
|
5523
5893
|
/** One sending domain on the wire: dates become ISO 8601 strings. */
|
|
5524
5894
|
const senderDomainSchema = selectSenderDomainSchema.extend({
|
|
5525
5895
|
dnsRecords: z.array(dnsRecordSchema),
|
|
5896
|
+
challengeVerifiedAt: z.iso.datetime().nullable(),
|
|
5526
5897
|
lastCheckedAt: z.iso.datetime().nullable(),
|
|
5527
5898
|
createdAt: z.iso.datetime(),
|
|
5528
5899
|
updatedAt: z.iso.datetime()
|
|
@@ -5563,10 +5934,10 @@ const domainDnsSetupSchema = z.object({
|
|
|
5563
5934
|
* braces, no dependency. A pattern field takes one pattern or a non-empty
|
|
5564
5935
|
* list of them, and a list is a disjunction.
|
|
5565
5936
|
*
|
|
5566
|
-
* It lives here rather than beside `
|
|
5567
|
-
* imports the Drizzle schema, which neither the wasmtime guest nor
|
|
5568
|
-
* Temporal workflow isolate can carry. This file imports one constant,
|
|
5569
|
-
* both can.
|
|
5937
|
+
* It lives here rather than beside `appIdFromName` in ./apps because that
|
|
5938
|
+
* module imports the Drizzle schema, which neither the wasmtime guest nor
|
|
5939
|
+
* the Temporal workflow isolate can carry. This file imports one constant,
|
|
5940
|
+
* so both can.
|
|
5570
5941
|
*
|
|
5571
5942
|
* The system rule: a name under SYSTEM_EVENT_PREFIX matches only a pattern
|
|
5572
5943
|
* whose *literal prefix* (the text before its first `*`) is itself under
|
|
@@ -5645,29 +6016,6 @@ function patternPlaceholder(pattern) {
|
|
|
5645
6016
|
* a guest returns with these schemas before anything acts on it; the guest
|
|
5646
6017
|
* SDK and the Node simulator produce and consume the same shapes.
|
|
5647
6018
|
*/
|
|
5648
|
-
/** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
|
|
5649
|
-
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
5650
|
-
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
|
|
5651
|
-
const DURATION_UNIT_MS = {
|
|
5652
|
-
ms: 1,
|
|
5653
|
-
s: 1e3,
|
|
5654
|
-
m: 6e4,
|
|
5655
|
-
h: 36e5,
|
|
5656
|
-
d: 864e5,
|
|
5657
|
-
w: 6048e5
|
|
5658
|
-
};
|
|
5659
|
-
/**
|
|
5660
|
-
* A duration in milliseconds. The simulator's virtual clock and the runner's
|
|
5661
|
-
* timers both need it, and neither may pull in Temporal's `msToNumber` (one
|
|
5662
|
-
* runs in the CLI, the other inside workflow code).
|
|
5663
|
-
*/
|
|
5664
|
-
function parseDuration(duration) {
|
|
5665
|
-
if (typeof duration === "number") return duration;
|
|
5666
|
-
const match = DURATION_PATTERN.exec(duration.trim());
|
|
5667
|
-
if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
|
|
5668
|
-
const unit = DURATION_UNIT_MS[match[2]];
|
|
5669
|
-
return Math.round(Number(match[1]) * unit);
|
|
5670
|
-
}
|
|
5671
6019
|
/** The event a journey runs for, or waits on: name, properties, and when. */
|
|
5672
6020
|
const guestEventSchema = z.object({
|
|
5673
6021
|
name: z.string().min(1),
|
|
@@ -5707,8 +6055,9 @@ const commandSchema = z.discriminatedUnion("name", [
|
|
|
5707
6055
|
/**
|
|
5708
6056
|
* The address this mail leaves as. The guest SDK fills in the
|
|
5709
6057
|
* journey's own whenever the call does not name one, so the host has
|
|
5710
|
-
* one resolution path and never reads the manifest to find it
|
|
5711
|
-
* absent on both is
|
|
6058
|
+
* one resolution path and never reads the manifest to find it. A
|
|
6059
|
+
* journey must name one, so absent on both is a release pushed before
|
|
6060
|
+
* that was true, and the `domain` gate refuses it.
|
|
5712
6061
|
*/
|
|
5713
6062
|
from: fromSchema.optional(),
|
|
5714
6063
|
/** Where a reply to this one mail goes, instead of the from-address. */
|
|
@@ -5852,11 +6201,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
|
|
|
5852
6201
|
const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
|
|
5853
6202
|
trigger: true,
|
|
5854
6203
|
purpose: true,
|
|
6204
|
+
enrollment: true,
|
|
6205
|
+
description: true,
|
|
5855
6206
|
from: true,
|
|
5856
6207
|
tags: true
|
|
5857
6208
|
}).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
|
|
5858
6209
|
sendClass: true,
|
|
5859
6210
|
verifyLink: true,
|
|
6211
|
+
unsubscribeLink: true,
|
|
5860
6212
|
propsSchema: true,
|
|
5861
6213
|
tags: true
|
|
5862
6214
|
}).extend({ kind: z.literal("template") })]);
|
|
@@ -5923,6 +6275,13 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
|
|
|
5923
6275
|
status: executionStatusSchema.optional(),
|
|
5924
6276
|
profileId: z.string().trim().min(1).max(200).optional(),
|
|
5925
6277
|
/**
|
|
6278
|
+
* One app's runs. An execution carries no app of its own: it is a run of
|
|
6279
|
+
* a journey, and the journey is what belongs to an app (ADR 0022), so the
|
|
6280
|
+
* filter resolves through `journeys.app_id`. Omitted means every app, and
|
|
6281
|
+
* an id the org does not have matches nobody.
|
|
6282
|
+
*/
|
|
6283
|
+
appId: appIdSchema.optional(),
|
|
6284
|
+
/**
|
|
5926
6285
|
* Only executions touched at or after this instant, by `updatedAt`: what a
|
|
5927
6286
|
* tail (`cow dev`) asks for so it sees both new runs and status changes in
|
|
5928
6287
|
* one filter. Inclusive on purpose, because two rows can share a
|
|
@@ -5949,74 +6308,24 @@ const cancelExecutionsBodySchema = z.object({ data: z.object({
|
|
|
5949
6308
|
const cancellingSchema = z.object({ cancelling: z.literal(true) });
|
|
5950
6309
|
|
|
5951
6310
|
//#endregion
|
|
5952
|
-
//#region ../../packages/shared/src/
|
|
6311
|
+
//#region ../../packages/shared/src/pushes.ts
|
|
5953
6312
|
/**
|
|
5954
|
-
*
|
|
5955
|
-
*
|
|
5956
|
-
* is the
|
|
6313
|
+
* The push lifecycle's wire contracts (ADR 0011): the artifacts `cow push`
|
|
6314
|
+
* uploads, the push itself, and the versions its compile produces. The app
|
|
6315
|
+
* `cow.json` names is the push unit (ADR 0022).
|
|
6316
|
+
*
|
|
6317
|
+
* Row-derived through drizzle-zod where a row is what the wire carries, so
|
|
6318
|
+
* the DTO cannot drift from the column set; the jsonb manifest gets its
|
|
6319
|
+
* explicit shared schema, because the column type is opaque to drizzle-zod.
|
|
5957
6320
|
*/
|
|
5958
|
-
const
|
|
6321
|
+
const artifactDigestParams = z.object({ digest: digestSchema });
|
|
5959
6322
|
/**
|
|
5960
|
-
*
|
|
5961
|
-
*
|
|
5962
|
-
*
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
orgId: z.string().startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id"),
|
|
5967
|
-
/**
|
|
5968
|
-
* Which project of the org this directory pushes to. One org can hold
|
|
5969
|
-
* several, one per repo, each with its own release sequence and its own
|
|
5970
|
-
* slice of the deployed journeys, so every project says which it is.
|
|
5971
|
-
*/
|
|
5972
|
-
project: projectNameSchema,
|
|
5973
|
-
/**
|
|
5974
|
-
* The consent purposes this project declares. They are org-wide, so two
|
|
5975
|
-
* projects declaring one key must agree on its label and default or the
|
|
5976
|
-
* deploy is refused; a deploy adds and updates them and never deletes
|
|
5977
|
-
* one, because profiles hold answers against them.
|
|
5978
|
-
*/
|
|
5979
|
-
purposes: purposesSchema.optional(),
|
|
5980
|
-
/** Overrides the API the CLI talks to; the hosted product needs none. */
|
|
5981
|
-
apiUrl: z.url().optional(),
|
|
5982
|
-
/**
|
|
5983
|
-
* Overrides the dashboard `cow login` opens. Paired with `apiUrl`: a
|
|
5984
|
-
* login against one Cowliss's dashboard yields a token the other's API
|
|
5985
|
-
* rejects, so a config that names an API names its dashboard too.
|
|
5986
|
-
*/
|
|
5987
|
-
webUrl: z.url().optional()
|
|
5988
|
-
}).meta({
|
|
5989
|
-
title: "cow.json",
|
|
5990
|
-
description: "A cow project: the organization and the project it pushes to."
|
|
5991
|
-
});
|
|
5992
|
-
|
|
5993
|
-
//#endregion
|
|
5994
|
-
//#region ../../packages/shared/src/pushes.ts
|
|
5995
|
-
/**
|
|
5996
|
-
* The push lifecycle's wire contracts (ADR 0011): the project a developer
|
|
5997
|
-
* runs `cow init` in, the artifacts `cow push` uploads, the push itself, and
|
|
5998
|
-
* the versions its compile produces.
|
|
5999
|
-
*
|
|
6000
|
-
* Row-derived through drizzle-zod where a row is what the wire carries, so
|
|
6001
|
-
* the DTO cannot drift from the column set; the jsonb manifest gets its
|
|
6002
|
-
* explicit shared schema, because the column type is opaque to drizzle-zod.
|
|
6003
|
-
*/
|
|
6004
|
-
const projectSchema = selectProjectSchema.extend({
|
|
6005
|
-
createdAt: z.iso.datetime(),
|
|
6006
|
-
updatedAt: z.iso.datetime()
|
|
6007
|
-
});
|
|
6008
|
-
/** `cow init` names the project after the folder it created. */
|
|
6009
|
-
const createProjectBodySchema = z.object({ data: z.object({ name: projectNameSchema }) });
|
|
6010
|
-
/** Which project to read: the one the caller's `cow.json` names. */
|
|
6011
|
-
const getProjectQuerySchema = z.object({ name: projectNameSchema });
|
|
6012
|
-
const artifactDigestParams = z.object({ digest: digestSchema });
|
|
6013
|
-
/**
|
|
6014
|
-
* Which kind of artifact the uploaded bytes are. Named by the client rather
|
|
6015
|
-
* than sniffed, because only the build knows whether a blob is a journey
|
|
6016
|
-
* bundle or the source tarball, and the value is metadata for the prune
|
|
6017
|
-
* sweep and the sandbox's cache rather than a trust boundary: what a module
|
|
6018
|
-
* is, is decided by the server-side compile step. `module` is absent on
|
|
6019
|
-
* purpose, since only that compile step ever writes one.
|
|
6323
|
+
* Which kind of artifact the uploaded bytes are. Named by the client rather
|
|
6324
|
+
* than sniffed, because only the build knows whether a blob is a journey
|
|
6325
|
+
* bundle or the source tarball, and the value is metadata for the prune
|
|
6326
|
+
* sweep and the sandbox's cache rather than a trust boundary: what a module
|
|
6327
|
+
* is, is decided by the server-side compile step. `module` is absent on
|
|
6328
|
+
* purpose, since only that compile step ever writes one.
|
|
6020
6329
|
*/
|
|
6021
6330
|
const putArtifactQuerySchema = z.object({ kind: z.enum(["bundle", "source"]) });
|
|
6022
6331
|
/**
|
|
@@ -6040,7 +6349,7 @@ const versionSummarySchema = selectVersionSchema.pick({
|
|
|
6040
6349
|
status: true,
|
|
6041
6350
|
moduleDigest: true,
|
|
6042
6351
|
pushId: true,
|
|
6043
|
-
|
|
6352
|
+
appId: true,
|
|
6044
6353
|
error: true
|
|
6045
6354
|
}).extend({ pushedAt: z.iso.datetime() });
|
|
6046
6355
|
/**
|
|
@@ -6069,12 +6378,21 @@ const createPushBodySchema = z.object({ data: z.object({
|
|
|
6069
6378
|
* the developer fixes by updating `@cowliss/cli`, and nothing is stored.
|
|
6070
6379
|
*/
|
|
6071
6380
|
manifest: manifestSchema.safeExtend({ protocol: z.literal(3) }),
|
|
6072
|
-
/** The `cow.json`
|
|
6073
|
-
|
|
6381
|
+
/** The `cow.json` app this push belongs to (ADR 0022). */
|
|
6382
|
+
appId: appIdSchema,
|
|
6383
|
+
/**
|
|
6384
|
+
* The `cow.json` organization. The credential already decides which org
|
|
6385
|
+
* is written to, so this is not how the server finds the org: it is the
|
|
6386
|
+
* developer saying which one they meant, and a disagreement is refused.
|
|
6387
|
+
* Without it, a push aimed at production but run with local credentials
|
|
6388
|
+
* lands silently whenever both orgs happen to hold an app of the same
|
|
6389
|
+
* slug, which is exactly the shape `apps/platform-workspace` has.
|
|
6390
|
+
*/
|
|
6391
|
+
orgId: orgIdSchema
|
|
6074
6392
|
}) });
|
|
6075
6393
|
const listPushesQuerySchema = paginationQuerySchema.extend({
|
|
6076
|
-
/** One
|
|
6077
|
-
|
|
6394
|
+
/** One app's own pushes; absent lists the org's. */
|
|
6395
|
+
appId: appIdSchema.optional() });
|
|
6078
6396
|
/**
|
|
6079
6397
|
* One key's version history, newest first. Both the key and the kind are
|
|
6080
6398
|
* required: journeys and templates share a key space, so `welcome` alone
|
|
@@ -6085,13 +6403,13 @@ const listVersionsQuerySchema = paginationQuerySchema.extend({
|
|
|
6085
6403
|
kind: z.enum(["journey", "template"])
|
|
6086
6404
|
});
|
|
6087
6405
|
/**
|
|
6088
|
-
* One key of
|
|
6089
|
-
* the code the server holds for it,
|
|
6406
|
+
* One key of an app as `cow status` and the MCP `status` tool report it:
|
|
6407
|
+
* the code the server holds for it, what state it is in, and what is still
|
|
6090
6408
|
* running. Journeys and templates share the shape, because a developer asks
|
|
6091
6409
|
* the same question of both; the two fields only a journey has are null on a
|
|
6092
6410
|
* template.
|
|
6093
6411
|
*/
|
|
6094
|
-
const
|
|
6412
|
+
const appStatusKeySchema = z.object({
|
|
6095
6413
|
kind: z.enum(["journey", "template"]),
|
|
6096
6414
|
key: z.string(),
|
|
6097
6415
|
/**
|
|
@@ -6100,34 +6418,107 @@ const projectStatusKeySchema = z.object({
|
|
|
6100
6418
|
* server holds. Null only for a journey whose versions are all pruned.
|
|
6101
6419
|
*/
|
|
6102
6420
|
latestVersion: versionSchema.nullable(),
|
|
6103
|
-
/**
|
|
6104
|
-
|
|
6421
|
+
/**
|
|
6422
|
+
* The journey's one state; null on a template, which has none. Built from
|
|
6423
|
+
* the column here rather than imported from `journeys.ts`, which reads
|
|
6424
|
+
* this module for its version summary.
|
|
6425
|
+
*/
|
|
6426
|
+
status: z.enum(journeyStatusEnum.enumValues).nullable(),
|
|
6105
6427
|
/** Executions still running or waiting. */
|
|
6106
6428
|
liveExecutions: z.number().int()
|
|
6107
6429
|
});
|
|
6108
6430
|
/**
|
|
6109
|
-
* Everything the server knows about one
|
|
6110
|
-
*
|
|
6431
|
+
* Everything the server knows about one app's keys, in one read: the whole
|
|
6432
|
+
* app rather than a page of it, because a manifest holds at most
|
|
6111
6433
|
* `PUSH_LIMITS.journeys + PUSH_LIMITS.templates` keys and the answer to "what
|
|
6112
|
-
* is my
|
|
6434
|
+
* is my app doing" is useless split across cursors.
|
|
6113
6435
|
*
|
|
6114
6436
|
* `warnings` is what a deploy used to answer with (ADR 0011 retired the
|
|
6115
6437
|
* deploy): a name a journey references that the organization does not
|
|
6116
6438
|
* define yet. They never block, because a segment or a webhook may be created
|
|
6117
6439
|
* right after a push.
|
|
6118
6440
|
*/
|
|
6119
|
-
const
|
|
6120
|
-
|
|
6121
|
-
keys: z.array(
|
|
6441
|
+
const appStatusSchema = z.object({
|
|
6442
|
+
appId: appIdSchema,
|
|
6443
|
+
keys: z.array(appStatusKeySchema),
|
|
6122
6444
|
warnings: z.array(z.string())
|
|
6123
6445
|
});
|
|
6124
6446
|
|
|
6447
|
+
//#endregion
|
|
6448
|
+
//#region ../../packages/shared/src/segments.ts
|
|
6449
|
+
const segmentSchema = selectSegmentSchema.extend({
|
|
6450
|
+
definition: segmentDefinitionSchema,
|
|
6451
|
+
createdAt: z.iso.datetime(),
|
|
6452
|
+
updatedAt: z.iso.datetime()
|
|
6453
|
+
});
|
|
6454
|
+
/** The detail view adds the materialized member count. */
|
|
6455
|
+
const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
|
|
6456
|
+
const segmentMemberSchema = z.object({
|
|
6457
|
+
segmentId: z.string(),
|
|
6458
|
+
orgId: z.string(),
|
|
6459
|
+
profileId: z.string(),
|
|
6460
|
+
enteredAt: z.iso.datetime()
|
|
6461
|
+
});
|
|
6462
|
+
/**
|
|
6463
|
+
* The builder's design-time sanity check: run one not-yet-persisted
|
|
6464
|
+
* definition over one app's existing profiles. The app is the same one the
|
|
6465
|
+
* segment would be created in (ADR 0022), so the count the builder shows is
|
|
6466
|
+
* the count saving it would produce. Members is a small sample of matching
|
|
6467
|
+
* profile ids, not the full member list: there is no segment row and no
|
|
6468
|
+
* membership entry, so there is no segmentId or enteredAt to report.
|
|
6469
|
+
*
|
|
6470
|
+
* The scan is bounded, so `memberCount` is the exact count for that app only
|
|
6471
|
+
* when `truncated` is false. When it is true the scan stopped at the cap,
|
|
6472
|
+
* and `memberCount` is the count over the first `scanned` profiles only.
|
|
6473
|
+
*/
|
|
6474
|
+
const previewSegmentBodySchema = z.object({ data: z.object({
|
|
6475
|
+
appId: appIdSchema,
|
|
6476
|
+
definition: segmentDefinitionSchema
|
|
6477
|
+
}) });
|
|
6478
|
+
const segmentPreviewSchema = z.object({
|
|
6479
|
+
memberCount: z.number().int().min(0),
|
|
6480
|
+
members: z.array(z.string()),
|
|
6481
|
+
/** Profiles the preview actually evaluated. */
|
|
6482
|
+
scanned: z.number().int().min(0),
|
|
6483
|
+
/** True when the scan hit its cap, so memberCount is a floor. */
|
|
6484
|
+
truncated: z.boolean()
|
|
6485
|
+
});
|
|
6486
|
+
const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
|
|
6487
|
+
const descriptionSchema = z.string().trim().max(500, `description must be at most ${500} characters`);
|
|
6488
|
+
/**
|
|
6489
|
+
* A segment belongs to exactly one app (ADR 0022), named here and never
|
|
6490
|
+
* again: it evaluates that app's profiles and that app's events, and an
|
|
6491
|
+
* update cannot move it, because the members it already has belong to the
|
|
6492
|
+
* app it was built for.
|
|
6493
|
+
*/
|
|
6494
|
+
const createSegmentBodySchema = z.object({ data: z.object({
|
|
6495
|
+
name: nameSchema,
|
|
6496
|
+
appId: appIdSchema,
|
|
6497
|
+
description: descriptionSchema.nullish(),
|
|
6498
|
+
definition: segmentDefinitionSchema
|
|
6499
|
+
}) });
|
|
6500
|
+
const updateSegmentBodySchema = z.object({ data: z.object({
|
|
6501
|
+
name: nameSchema.optional(),
|
|
6502
|
+
description: descriptionSchema.nullish(),
|
|
6503
|
+
definition: segmentDefinitionSchema.optional()
|
|
6504
|
+
}).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0, { message: "at least one field (name, description or definition) is required" }) });
|
|
6505
|
+
/**
|
|
6506
|
+
* `q` is a substring search over the segment's name; `appId` narrows to one
|
|
6507
|
+
* app's segments, and omitted means every app. An id the org does not have
|
|
6508
|
+
* matches nobody rather than 404ing, like every other list filter.
|
|
6509
|
+
*/
|
|
6510
|
+
const listSegmentsQuerySchema = paginationQuerySchema.extend({
|
|
6511
|
+
q: searchQuerySchema,
|
|
6512
|
+
appId: appIdSchema.optional()
|
|
6513
|
+
});
|
|
6514
|
+
const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
|
|
6515
|
+
|
|
6125
6516
|
//#endregion
|
|
6126
6517
|
//#region ../../packages/shared/src/journeys.ts
|
|
6127
6518
|
/**
|
|
6128
6519
|
* Journeys as the API reports them: the rows a push derives from its
|
|
6129
6520
|
* manifest, one per (org, key). Nothing here is authored through the API, so
|
|
6130
|
-
* the only write is the
|
|
6521
|
+
* the only write is the status.
|
|
6131
6522
|
*
|
|
6132
6523
|
* Derived from the Drizzle table via drizzle-zod so the wire DTO and the row
|
|
6133
6524
|
* share one source of truth; the jsonb columns get explicit wire schemas
|
|
@@ -6140,11 +6531,19 @@ const journeyTriggerSchema = triggerSchema;
|
|
|
6140
6531
|
* control flow is whatever its code does at run time.
|
|
6141
6532
|
*/
|
|
6142
6533
|
const journeySpineEntrySchema = spineEntrySchema;
|
|
6534
|
+
/**
|
|
6535
|
+
* The one operational state a manager sets (ADR 0018). `on` lets recipients
|
|
6536
|
+
* enroll and executions run; `off` enrolls nobody and lets what is in flight
|
|
6537
|
+
* finish; `paused` enrolls nobody and holds what is in flight before its next
|
|
6538
|
+
* step. Turning a paused journey on resumes it.
|
|
6539
|
+
*/
|
|
6540
|
+
const JOURNEY_STATUSES = journeyStatusEnum.enumValues;
|
|
6541
|
+
const journeyStatusSchema = z.enum(JOURNEY_STATUSES);
|
|
6143
6542
|
const journeySchema = selectJourneySchema.extend({
|
|
6144
6543
|
trigger: journeyTriggerSchema,
|
|
6145
6544
|
purpose: consentPurposeKeySchema,
|
|
6146
6545
|
spine: z.array(journeySpineEntrySchema),
|
|
6147
|
-
|
|
6546
|
+
status: journeyStatusSchema,
|
|
6148
6547
|
/**
|
|
6149
6548
|
* The newest version of this key, whatever it compiled to, so a list can
|
|
6150
6549
|
* say "pushed 2 hours ago" and "compiling" without a second read. What the
|
|
@@ -6157,14 +6556,29 @@ const journeySchema = selectJourneySchema.extend({
|
|
|
6157
6556
|
updatedAt: z.iso.datetime()
|
|
6158
6557
|
});
|
|
6159
6558
|
/**
|
|
6559
|
+
* One journey, with the segment it owns when its trigger inlines a
|
|
6560
|
+
* definition (ADR 0016): the same row and member count the segments API
|
|
6561
|
+
* reports, so the journey's page can show who is in its segment without a
|
|
6562
|
+
* second concept. Null for an event trigger.
|
|
6563
|
+
*
|
|
6564
|
+
* On the read of one journey only. A list would be one member count per row,
|
|
6565
|
+
* and a list already shows the trigger.
|
|
6566
|
+
*/
|
|
6567
|
+
const journeyDetailSchema = journeySchema.extend({ segment: segmentDetailSchema.nullable() });
|
|
6568
|
+
/**
|
|
6160
6569
|
* Journey list query. `q` is a substring search over the key and the tags,
|
|
6161
6570
|
* the two things an author names a journey by; `tag` is "has this tag",
|
|
6162
6571
|
* exact and case-sensitive, so a badge in the table is the way into it.
|
|
6163
6572
|
* Both are server-side, like every other list filter.
|
|
6573
|
+
*
|
|
6574
|
+
* `appId` narrows to one app's journeys (ADR 0022); omitted means every
|
|
6575
|
+
* app. An id the org does not have matches nobody, the same empty answer
|
|
6576
|
+
* `journeys.setStatus` gives it.
|
|
6164
6577
|
*/
|
|
6165
6578
|
const listJourneysQuerySchema = paginationQuerySchema.extend({
|
|
6166
|
-
/** On or
|
|
6167
|
-
|
|
6579
|
+
/** On, off or paused; the journey's one state. */
|
|
6580
|
+
status: journeyStatusSchema.optional(),
|
|
6581
|
+
appId: appIdSchema.optional(),
|
|
6168
6582
|
q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
|
|
6169
6583
|
tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
|
|
6170
6584
|
});
|
|
@@ -6192,19 +6606,25 @@ const journeyStatsSchema = z.object({
|
|
|
6192
6606
|
deliveries: z.object({ byStatus: z.record(deliveryStatusSchema, z.number().int()) })
|
|
6193
6607
|
});
|
|
6194
6608
|
/**
|
|
6195
|
-
* Which journeys the
|
|
6196
|
-
* journey of one
|
|
6197
|
-
* both would have to decide which it
|
|
6609
|
+
* Which journeys the status acts on, and which state to put them in: the
|
|
6610
|
+
* keys the caller named, or every journey of one app. Exactly one of the
|
|
6611
|
+
* two selectors, because a call carrying both would have to decide which it
|
|
6612
|
+
* meant.
|
|
6198
6613
|
*
|
|
6199
6614
|
* A list rather than one key per call so every surface can do what `cow
|
|
6200
|
-
* enable` does: name several keys, or `--all` (which is `
|
|
6201
|
-
* server-side, so an agent turning
|
|
6615
|
+
* enable` does: name several keys, or `--all` (which is `appId`, resolved
|
|
6616
|
+
* server-side, so an agent turning an app on is one call too).
|
|
6617
|
+
*
|
|
6618
|
+
* One body for three states rather than a route per verb (ADR 0018): the
|
|
6619
|
+
* transitions differ in what they do to what is in flight, not in who may
|
|
6620
|
+
* ask or what they name.
|
|
6202
6621
|
*/
|
|
6203
|
-
const
|
|
6622
|
+
const setJourneysStatusBodySchema = z.object({ data: z.object({
|
|
6204
6623
|
keys: z.array(journeyKeySchema).min(1).max(PUSH_LIMITS.journeys).optional(),
|
|
6205
|
-
/** Every journey of this
|
|
6206
|
-
|
|
6207
|
-
|
|
6624
|
+
/** Every journey of this app, by the id `cow.json` carries. */
|
|
6625
|
+
appId: appIdSchema.optional(),
|
|
6626
|
+
status: journeyStatusSchema
|
|
6627
|
+
}).refine((data) => data.keys === void 0 !== (data.appId === void 0), { message: "name keys or an app, not both" }) });
|
|
6208
6628
|
/**
|
|
6209
6629
|
* Dry run: one real profile, the journey's latest ready version, and every
|
|
6210
6630
|
* send gated into a `would_*` row instead of a message. The body is the v1
|
|
@@ -6212,6 +6632,106 @@ const setJourneysEnabledBodySchema = z.object({ data: z.object({
|
|
|
6212
6632
|
* now and a v1 instance was a Temporal read.
|
|
6213
6633
|
*/
|
|
6214
6634
|
const dryRunJourneyBodySchema = z.object({ data: z.object({ profileId: z.string().min(1) }) });
|
|
6635
|
+
/**
|
|
6636
|
+
* Enrollment (ADR 0015): what turning a journey on would do, or is doing,
|
|
6637
|
+
* to the people already in its audience.
|
|
6638
|
+
*
|
|
6639
|
+
* The same three numbers answer both questions, so they are one schema: a
|
|
6640
|
+
* preview walks the whole segment counting, and the running job counts the
|
|
6641
|
+
* same way as it goes. `scanned` is the segment's current membership,
|
|
6642
|
+
* `enrolled` the ones that entered (or in a preview would), and the skips
|
|
6643
|
+
* say why the rest did not.
|
|
6644
|
+
*/
|
|
6645
|
+
const enrollmentCountsSchema = z.object({
|
|
6646
|
+
scanned: z.number().int(),
|
|
6647
|
+
enrolled: z.number().int(),
|
|
6648
|
+
skipped: z.object({
|
|
6649
|
+
/** They have been through this journey, and it enrolls once. */
|
|
6650
|
+
completed: z.number().int(),
|
|
6651
|
+
/** They finished recently and the journey's cooldown has not elapsed. */
|
|
6652
|
+
cooldown: z.number().int(),
|
|
6653
|
+
/** They are in the journey right now. */
|
|
6654
|
+
running: z.number().int()
|
|
6655
|
+
})
|
|
6656
|
+
});
|
|
6657
|
+
/**
|
|
6658
|
+
* How each skip is said to a person, in the order the surfaces list them.
|
|
6659
|
+
* The confirm screen, the progress card and `cow enable` all report the same
|
|
6660
|
+
* three numbers, and the words for them live here so the three cannot drift:
|
|
6661
|
+
* `tile` names the count on its own, `reason` completes "N ...".
|
|
6662
|
+
*/
|
|
6663
|
+
const ENROLLMENT_SKIP_WORDS = {
|
|
6664
|
+
completed: {
|
|
6665
|
+
tile: "Already been through",
|
|
6666
|
+
reason: "have already been through it"
|
|
6667
|
+
},
|
|
6668
|
+
cooldown: {
|
|
6669
|
+
tile: "Too recently",
|
|
6670
|
+
reason: "went through it too recently"
|
|
6671
|
+
},
|
|
6672
|
+
running: {
|
|
6673
|
+
tile: "In it already",
|
|
6674
|
+
reason: "are in it right now"
|
|
6675
|
+
}
|
|
6676
|
+
};
|
|
6677
|
+
const enrollmentStatusSchema = enrollmentCountsSchema.extend({
|
|
6678
|
+
/**
|
|
6679
|
+
* `running` while the job works, `done` when it finished the segment,
|
|
6680
|
+
* `cancelled` when someone stopped it, `stopped` when the journey itself
|
|
6681
|
+
* stopped being on under it, `failed` when it could not finish. On any of
|
|
6682
|
+
* the three that end early the counts are what it reached, and the people
|
|
6683
|
+
* it already enrolled stay in the journey.
|
|
6684
|
+
*/
|
|
6685
|
+
state: z.enum([
|
|
6686
|
+
"running",
|
|
6687
|
+
"done",
|
|
6688
|
+
"cancelled",
|
|
6689
|
+
"stopped",
|
|
6690
|
+
"failed"
|
|
6691
|
+
]) });
|
|
6692
|
+
/**
|
|
6693
|
+
* What a journey turned on says about the audience it now has waiting.
|
|
6694
|
+
*
|
|
6695
|
+
* It rides on the status response rather than being fetched separately so
|
|
6696
|
+
* that every surface that moves the control sees it: `cow enable` prints it,
|
|
6697
|
+
* and the MCP tool, which is planned from the contract and has no code of
|
|
6698
|
+
* its own, returns it. An agent that cannot start the enrollment can still
|
|
6699
|
+
* tell the person who can how many people it would reach and where to go.
|
|
6700
|
+
*
|
|
6701
|
+
* Null on a journey whose trigger is an event: there is no standing audience
|
|
6702
|
+
* to reach. `counts` is null when the numbers could not be worked out just
|
|
6703
|
+
* then; the journey is on either way, because a count nobody could read is
|
|
6704
|
+
* no reason to leave the switch off.
|
|
6705
|
+
*/
|
|
6706
|
+
const enrollmentOfferSchema = z.object({
|
|
6707
|
+
counts: enrollmentCountsSchema.nullable(),
|
|
6708
|
+
/** The journey's page, where the offer can be accepted. */
|
|
6709
|
+
url: z.string()
|
|
6710
|
+
});
|
|
6711
|
+
/**
|
|
6712
|
+
* A journey as the status route answers, carrying that offer. Null on every
|
|
6713
|
+
* status but `on`: a journey turned off or held reaches nobody, so there is
|
|
6714
|
+
* no audience to offer.
|
|
6715
|
+
*/
|
|
6716
|
+
const journeyWithOfferSchema = journeySchema.extend({ enrollment: enrollmentOfferSchema.nullable() });
|
|
6717
|
+
/**
|
|
6718
|
+
* What holding a journey until a given day would end (ADR 0018). A wait
|
|
6719
|
+
* that comes due while the journey is held ends that person's run, so
|
|
6720
|
+
* before putting one on hold it is worth knowing how many runs that is.
|
|
6721
|
+
*
|
|
6722
|
+
* The day is the caller's, not a fixed horizon: "until tomorrow" and "until
|
|
6723
|
+
* next month" are different decisions, and the numbers behind them are what
|
|
6724
|
+
* tell them apart.
|
|
6725
|
+
*/
|
|
6726
|
+
const waitsDueQuerySchema = z.object({
|
|
6727
|
+
/** Count the waits that come due before this instant. */
|
|
6728
|
+
before: z.iso.datetime() });
|
|
6729
|
+
const waitsDueSchema = z.object({
|
|
6730
|
+
/** Executions in flight whose next step comes due before then. */
|
|
6731
|
+
due: z.number().int(),
|
|
6732
|
+
/** Executions in flight altogether, due or not. */
|
|
6733
|
+
inFlight: z.number().int()
|
|
6734
|
+
});
|
|
6215
6735
|
|
|
6216
6736
|
//#endregion
|
|
6217
6737
|
//#region ../../packages/shared/src/journeys-v2/sandbox.ts
|
|
@@ -6330,7 +6850,7 @@ const RESEND_MAX_TAGS = 50;
|
|
|
6330
6850
|
const recipientSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "Every recipient must be an address (\"ada@acme.com\") or a name and address (\"Ada <ada@acme.com>\")." });
|
|
6331
6851
|
const recipientArraySchema = z.array(recipientSchema).min(1).max(50);
|
|
6332
6852
|
/** Resend takes one address or a list wherever it takes recipients. */
|
|
6333
|
-
const recipientsSchema = z.union([recipientSchema, recipientArraySchema]);
|
|
6853
|
+
const recipientsSchema$1 = z.union([recipientSchema, recipientArraySchema]);
|
|
6334
6854
|
/**
|
|
6335
6855
|
* Resend's wording for an absent required field. Three fields can be
|
|
6336
6856
|
* missing: `to`, `subject`, and a body. Which answer each one gets is read
|
|
@@ -6361,11 +6881,11 @@ const templateSlotSchema = z.strictObject({
|
|
|
6361
6881
|
/** Send body: Resend's fields, minus the ones Cowliss does not serve. */
|
|
6362
6882
|
const resendSendBodySchema = z.strictObject({
|
|
6363
6883
|
/**
|
|
6364
|
-
*
|
|
6365
|
-
*
|
|
6366
|
-
*
|
|
6884
|
+
* Required, exactly as Resend requires it. There is no address to fall
|
|
6885
|
+
* back to: a send leaves from a domain the organization verified, and
|
|
6886
|
+
* nothing else. Missing is its own issue for the same reason `to`'s is.
|
|
6367
6887
|
*/
|
|
6368
|
-
from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional(),
|
|
6888
|
+
from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional().nonoptional({ error: MISSING("from") }),
|
|
6369
6889
|
/**
|
|
6370
6890
|
* Required, and the absence is its own issue rather than the union's:
|
|
6371
6891
|
* `optional().nonoptional()` lets the value through to the union when
|
|
@@ -6382,9 +6902,9 @@ const resendSendBodySchema = z.strictObject({
|
|
|
6382
6902
|
subject: z.string().trim().min(1).max(RESEND_MAX_SUBJECT).optional(),
|
|
6383
6903
|
html: z.string().optional(),
|
|
6384
6904
|
text: z.string().optional(),
|
|
6385
|
-
cc: recipientsSchema.optional(),
|
|
6386
|
-
bcc: recipientsSchema.optional(),
|
|
6387
|
-
reply_to: recipientsSchema.optional(),
|
|
6905
|
+
cc: recipientsSchema$1.optional(),
|
|
6906
|
+
bcc: recipientsSchema$1.optional(),
|
|
6907
|
+
reply_to: recipientsSchema$1.optional(),
|
|
6388
6908
|
headers: z.record(z.string(), z.string()).optional(),
|
|
6389
6909
|
/** Accepted and dropped: Cowliss tags a delivery by its own fields. */
|
|
6390
6910
|
tags: z.array(z.object({
|
|
@@ -6573,133 +7093,76 @@ const listReviewQuerySchema = paginationQuerySchema.extend({
|
|
|
6573
7093
|
});
|
|
6574
7094
|
|
|
6575
7095
|
//#endregion
|
|
6576
|
-
//#region ../../packages/shared/src/
|
|
7096
|
+
//#region ../../packages/shared/src/send-email.ts
|
|
6577
7097
|
/**
|
|
6578
|
-
*
|
|
6579
|
-
*
|
|
6580
|
-
*
|
|
6581
|
-
*
|
|
6582
|
-
*
|
|
6583
|
-
* `appId` scopes the EVENT side of a definition only. Traits are
|
|
6584
|
-
* per-profile and profiles merge across apps, so there is nothing
|
|
6585
|
-
* app-shaped to filter on the trait side.
|
|
7098
|
+
* `POST /v1/emails`: the first-party transactional send, the one a
|
|
7099
|
+
* `@cowliss/sdk` client calls. The Resend-compatible facade (ADR 0014) is
|
|
7100
|
+
* the same send in somebody else's wire format; this is ours, so it is
|
|
7101
|
+
* camelCase, enveloped, and answers with our error codes.
|
|
6586
7102
|
*
|
|
6587
|
-
*
|
|
6588
|
-
*
|
|
6589
|
-
*
|
|
6590
|
-
* The object is strict: a definition holding the retired `sourceId` key
|
|
6591
|
-
* (which meant the app) fails loudly instead of parsing as an unfiltered
|
|
6592
|
-
* definition that evaluates over every app. There is deliberately no pipe
|
|
6593
|
-
* filter here — filtering by source is a later feature, and accepting one
|
|
6594
|
-
* now would make a stale `sourceId` parse as a filter on a pipe that does
|
|
6595
|
-
* not exist and silently match nothing.
|
|
7103
|
+
* The limits are deliberately the same numbers as the facade's: two shapes
|
|
7104
|
+
* over one send path must not accept different messages.
|
|
6596
7105
|
*/
|
|
6597
|
-
const
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
"contains"
|
|
6607
|
-
];
|
|
6608
|
-
/** Operators that read no comparison value: presence of the key is the test. */
|
|
6609
|
-
const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
|
|
6610
|
-
const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
|
|
6611
|
-
const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
|
|
6612
|
-
kind: z.literal("trait"),
|
|
6613
|
-
name: predicateNameSchema,
|
|
6614
|
-
op: z.enum(SEGMENT_TRAIT_OPS),
|
|
7106
|
+
const addressSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "An address is \"ada@acme.com\" or a name and address, \"Ada <ada@acme.com>\"." });
|
|
7107
|
+
/** One address or a list, wherever a message names recipients. */
|
|
7108
|
+
const recipientsSchema = z.union([addressSchema, z.array(addressSchema).min(1).max(50)]);
|
|
7109
|
+
/** A template this organization pushed, plus the props it declares. */
|
|
7110
|
+
const templateSchema$1 = z.strictObject({
|
|
7111
|
+
key: journeyKeySchema,
|
|
7112
|
+
props: z.record(z.string(), z.unknown()).optional()
|
|
7113
|
+
});
|
|
7114
|
+
const sendEmailDataSchema = z.strictObject({
|
|
6615
7115
|
/**
|
|
6616
|
-
*
|
|
6617
|
-
*
|
|
6618
|
-
*
|
|
6619
|
-
* form-field-friendly reading rather than strict JSON equality:
|
|
6620
|
-
* numeric-looking strings are compared as numbers for eq/neq and for
|
|
6621
|
-
* ordering ("150" matches 150, and orders like it), and "true"/"false"
|
|
6622
|
-
* are compared as booleans for eq/neq, trimmed and case-insensitively
|
|
6623
|
-
* (" TRUE " reads as true). Coercion needs both sides to agree on a
|
|
6624
|
-
* type: "0" never equals false. The numeric net is as wide as
|
|
6625
|
-
* `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
|
|
6626
|
-
* matters most for opaque ids: "007" is authored as the number 7.
|
|
6627
|
-
*
|
|
6628
|
-
* contains reads three ways. Against an array trait it is membership
|
|
6629
|
-
* under that same equality, so "true" matches `[true]` and "1" matches
|
|
6630
|
-
* `[1, 2]`. Against a string trait it is a plain substring search with
|
|
6631
|
-
* no coercion, since substrings only mean something between strings.
|
|
6632
|
-
* Against anything else it never matches. Ordering never coerces
|
|
6633
|
-
* booleans.
|
|
7116
|
+
* The pipe the message belongs to. A source id and only a source id: the
|
|
7117
|
+
* Resend facade takes an app id in its base URL because the Resend SDK
|
|
7118
|
+
* has nowhere else to put one, and nothing else on the wire does.
|
|
6634
7119
|
*/
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
/** How many matching events the predicate counts as "performed". */
|
|
6641
|
-
atLeast: z.number().int().min(1).default(1),
|
|
6642
|
-
/** Rolling window, relative to evaluation time; absent means all history. */
|
|
6643
|
-
withinDays: z.number().int().min(1).optional()
|
|
6644
|
-
})]).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" });
|
|
6645
|
-
const appIdSchema = z.string().trim().min(1);
|
|
6646
|
-
const segmentDefinitionSchema = z.strictObject({
|
|
6647
|
-
match: z.enum(["all", "any"]).default("all"),
|
|
7120
|
+
sourceId: sourceIdSchema,
|
|
7121
|
+
to: recipientsSchema,
|
|
7122
|
+
cc: recipientsSchema.optional(),
|
|
7123
|
+
bcc: recipientsSchema.optional(),
|
|
7124
|
+
replyTo: recipientsSchema.optional(),
|
|
6648
7125
|
/**
|
|
6649
|
-
*
|
|
6650
|
-
*
|
|
6651
|
-
*
|
|
7126
|
+
* An address on a domain this organization verified. There is no other.
|
|
7127
|
+
* Shape is checked here, the same as the Resend body checks it, so an
|
|
7128
|
+
* address that is not one is a 422 naming the field rather than a 403
|
|
7129
|
+
* telling the caller to verify a domain they never named.
|
|
6652
7130
|
*/
|
|
6653
|
-
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
6683
|
-
/** Profiles the preview actually evaluated. */
|
|
6684
|
-
scanned: z.number().int().min(0),
|
|
6685
|
-
/** True when the scan hit its cap, so memberCount is a floor. */
|
|
6686
|
-
truncated: z.boolean()
|
|
7131
|
+
from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }),
|
|
7132
|
+
subject: z.string().max(RESEND_MAX_SUBJECT).optional(),
|
|
7133
|
+
html: z.string().optional(),
|
|
7134
|
+
text: z.string().optional(),
|
|
7135
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
7136
|
+
template: templateSchema$1.optional()
|
|
7137
|
+
}).superRefine((body, ctx) => {
|
|
7138
|
+
const hasBody = body.html !== void 0 || body.text !== void 0;
|
|
7139
|
+
if (body.template) {
|
|
7140
|
+
for (const field of [
|
|
7141
|
+
"subject",
|
|
7142
|
+
"html",
|
|
7143
|
+
"text"
|
|
7144
|
+
]) if (body[field] !== void 0) ctx.addIssue({
|
|
7145
|
+
code: "custom",
|
|
7146
|
+
path: [field],
|
|
7147
|
+
message: `A message names a template or writes its own body, not both. Drop \`${field}\`.`
|
|
7148
|
+
});
|
|
7149
|
+
return;
|
|
7150
|
+
}
|
|
7151
|
+
if (!hasBody) ctx.addIssue({
|
|
7152
|
+
code: "custom",
|
|
7153
|
+
path: ["html"],
|
|
7154
|
+
message: "A message needs a template, or an html or text body."
|
|
7155
|
+
});
|
|
7156
|
+
if (body.subject === void 0) ctx.addIssue({
|
|
7157
|
+
code: "custom",
|
|
7158
|
+
path: ["subject"],
|
|
7159
|
+
message: "A message that writes its own body needs a subject."
|
|
7160
|
+
});
|
|
6687
7161
|
});
|
|
6688
|
-
const
|
|
6689
|
-
const
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
description: descriptionSchema.nullish(),
|
|
6693
|
-
definition: segmentDefinitionSchema
|
|
6694
|
-
}) });
|
|
6695
|
-
const updateSegmentBodySchema = z.object({ data: z.object({
|
|
6696
|
-
name: nameSchema.optional(),
|
|
6697
|
-
description: descriptionSchema.nullish(),
|
|
6698
|
-
definition: segmentDefinitionSchema.optional()
|
|
6699
|
-
}).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0, { message: "at least one field (name, description or definition) is required" }) });
|
|
6700
|
-
/** `q` is a substring search over the segment's name. */
|
|
6701
|
-
const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
|
|
6702
|
-
const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
|
|
7162
|
+
const sendEmailBodySchema = z.object({ data: sendEmailDataSchema });
|
|
7163
|
+
const sendEmailResultSchema = z.object({
|
|
7164
|
+
/** The delivery this send left behind: the row in Deliveries. */
|
|
7165
|
+
deliveryId: z.string() });
|
|
6703
7166
|
|
|
6704
7167
|
//#endregion
|
|
6705
7168
|
//#region ../../packages/shared/src/settings.ts
|
|
@@ -6722,9 +7185,9 @@ const orgSettingsDtoSchema = z.object({
|
|
|
6722
7185
|
/**
|
|
6723
7186
|
* Clerk's slug and display name for the org, cached by the API and
|
|
6724
7187
|
* READ-ONLY here: Clerk owns them, and the org profile is edited in
|
|
6725
|
-
* Clerk's own UI. Exposed because
|
|
6726
|
-
*
|
|
6727
|
-
*
|
|
7188
|
+
* Clerk's own UI. Exposed because a template test-send composes its
|
|
7189
|
+
* from-address out of them (`orgFromAddress`). Null until a member has hit
|
|
7190
|
+
* the dashboard API once.
|
|
6728
7191
|
*/
|
|
6729
7192
|
slug: z.string().nullable(),
|
|
6730
7193
|
name: z.string().nullable()
|
|
@@ -6856,13 +7319,19 @@ const listSuppressionsQuerySchema = paginationQuerySchema.extend({ q: searchQuer
|
|
|
6856
7319
|
*/
|
|
6857
7320
|
const templateSchema = z.object({
|
|
6858
7321
|
key: journeyKeySchema,
|
|
6859
|
-
/** The
|
|
6860
|
-
|
|
7322
|
+
/** The app whose push owns this key (ADR 0009, ADR 0022). */
|
|
7323
|
+
appId: z.string(),
|
|
6861
7324
|
/** The author's labels; the dashboard's only grouping. */
|
|
6862
7325
|
tags: tagsSchema,
|
|
6863
7326
|
sendClass: z.enum(SEND_CLASSES),
|
|
6864
7327
|
/** True when the template asks for a signed `verifyUrl` prop at send time. */
|
|
6865
7328
|
verifyLink: z.boolean(),
|
|
7329
|
+
/**
|
|
7330
|
+
* True when the template asks for a signed `unsubscribeUrl` prop at send
|
|
7331
|
+
* time. A marketing template without one (or without rendering the link)
|
|
7332
|
+
* gets a platform footer with the link appended at send time instead.
|
|
7333
|
+
*/
|
|
7334
|
+
unsubscribeLink: z.boolean().default(false),
|
|
6866
7335
|
/** JSON Schema of the template's props, as `cow build` converted them. */
|
|
6867
7336
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
6868
7337
|
/** The newest version of this key, whatever it compiled to. */
|
|
@@ -6912,6 +7381,20 @@ const userSegmentSchema = z.object({
|
|
|
6912
7381
|
});
|
|
6913
7382
|
const listUserSegmentsQuerySchema = paginationQuerySchema;
|
|
6914
7383
|
/**
|
|
7384
|
+
* One journey the profile ran to completion
|
|
7385
|
+
* (GET /v1/users/:profileId/runs): which journey it was, when it finished,
|
|
7386
|
+
* and the execution it finished, which is null once that execution is no
|
|
7387
|
+
* longer kept. Paged on (completedAt, id) newest first, so the cursor's
|
|
7388
|
+
* sortAt slot carries the completion time.
|
|
7389
|
+
*/
|
|
7390
|
+
const userRunSchema = z.object({
|
|
7391
|
+
id: z.string(),
|
|
7392
|
+
journeyKey: z.string(),
|
|
7393
|
+
completedAt: z.iso.datetime(),
|
|
7394
|
+
executionId: z.string().nullable()
|
|
7395
|
+
});
|
|
7396
|
+
const listUserRunsQuerySchema = paginationQuerySchema;
|
|
7397
|
+
/**
|
|
6915
7398
|
* Profile list query. `q` is a free-text substring search over the
|
|
6916
7399
|
* profile's identifier values and the identity traits in
|
|
6917
7400
|
* PROFILE_SEARCH_TRAITS; absent means no filter. An empty or
|
|
@@ -6923,10 +7406,17 @@ const listUserSegmentsQuerySchema = paginationQuerySchema;
|
|
|
6923
7406
|
* as text (the jsonb value rendered with ->>), so "who is on the pro plan" is
|
|
6924
7407
|
* one request. `traitValue` without `trait` is meaningless and is a 422: the
|
|
6925
7408
|
* value names no key to look in.
|
|
7409
|
+
*
|
|
7410
|
+
* `appId` narrows to one app's profiles (ADR 0022); omitted means every app.
|
|
7411
|
+
* An id the org does not have simply matches nobody, like `segmentId`. `q`
|
|
7412
|
+
* still searches identifier values across the org, so an address two apps
|
|
7413
|
+
* know answers with one profile per app.
|
|
6926
7414
|
*/
|
|
6927
7415
|
const listUsersQuerySchema = paginationQuerySchema.extend({
|
|
6928
7416
|
direction: sortDirectionSchema.default("asc"),
|
|
6929
7417
|
q: searchQuerySchema,
|
|
7418
|
+
/** One app's profiles. An unknown id matches nobody, it is not a 404. */
|
|
7419
|
+
appId: appIdSchema.optional(),
|
|
6930
7420
|
trait: z.string().trim().min(1).max(200).optional(),
|
|
6931
7421
|
traitValue: z.string().trim().min(1).max(500).optional(),
|
|
6932
7422
|
/** Members of one segment. An unknown id matches nobody, it is not a 404. */
|
|
@@ -6952,13 +7442,32 @@ const findUserQuerySchema = z.object({ identifier: z.string().trim().min(3, "ide
|
|
|
6952
7442
|
const userDetailSchema = profileDtoSchema.extend({ mergedIds: z.array(z.string()) });
|
|
6953
7443
|
const updateConsentBodySchema = z.object({ data: consentPatchSchema });
|
|
6954
7444
|
/**
|
|
6955
|
-
*
|
|
6956
|
-
*
|
|
7445
|
+
* What erasure did, so the dashboard action can say "n journey instances
|
|
7446
|
+
* terminated". `profileIds` is the people that were erased: one id for
|
|
7447
|
+
* `DELETE /v1/users/:profileId`, one per app for an erasure by identifier
|
|
7448
|
+
* (ADR 0022). They are the survivor ids, the ones anyone could have
|
|
7449
|
+
* addressed; the merged-away ids that went with them are not listed,
|
|
7450
|
+
* because nothing ever showed them as a person.
|
|
6957
7451
|
*/
|
|
6958
7452
|
const eraseUserResponseSchema = z.object({
|
|
6959
|
-
|
|
7453
|
+
profileIds: z.array(z.string()),
|
|
6960
7454
|
terminatedInstances: z.number().int().min(0)
|
|
6961
7455
|
});
|
|
7456
|
+
/**
|
|
7457
|
+
* Erase everyone one identifier names (POST /v1/users/erase), across the
|
|
7458
|
+
* organization's apps. Same erasure as by id, addressed by
|
|
7459
|
+
* `{ kind, value }` because that is what a person asking to be forgotten
|
|
7460
|
+
* gives you; a value nobody in the org carries is a 404.
|
|
7461
|
+
*/
|
|
7462
|
+
const eraseUserByIdentifierBodySchema = z.object({ data: identifierRefSchema });
|
|
7463
|
+
/**
|
|
7464
|
+
* The same addressing for the export (POST /v1/users/export). A body rather
|
|
7465
|
+
* than a query string for the same reason erasure uses one: a query string
|
|
7466
|
+
* is part of the URL, and an access log, a proxy log and a browser history
|
|
7467
|
+
* all keep it, so an address must not travel there. The method reads
|
|
7468
|
+
* nothing but says where the identifier goes.
|
|
7469
|
+
*/
|
|
7470
|
+
const exportUserByIdentifierBodySchema = z.object({ data: identifierRefSchema });
|
|
6962
7471
|
/** One address-ledger row on the export; dates are ISO 8601 strings. */
|
|
6963
7472
|
const emailAddressSchema = selectEmailAddressSchema.extend({
|
|
6964
7473
|
verifiedAt: z.iso.datetime().nullable(),
|
|
@@ -6971,14 +7480,20 @@ const journeyRunDtoSchema = selectJourneyRunSchema.extend({
|
|
|
6971
7480
|
createdAt: z.iso.datetime()
|
|
6972
7481
|
});
|
|
6973
7482
|
/**
|
|
6974
|
-
*
|
|
6975
|
-
*
|
|
6976
|
-
*
|
|
6977
|
-
*
|
|
7483
|
+
* The identity's whole data record in one JSON document (GDPR
|
|
7484
|
+
* portability). Each section reuses the wire DTO of the store it came
|
|
7485
|
+
* from, so the export cannot drift from what the dashboard already shows.
|
|
7486
|
+
*
|
|
7487
|
+
* `users` is who the document is about: one profile for
|
|
7488
|
+
* `GET /v1/users/:profileId/export`, one per app when the export is
|
|
7489
|
+
* addressed by identifier (ADR 0022). The other sections are the union over
|
|
7490
|
+
* all of them and over everything merged into them, because that is the
|
|
7491
|
+
* data the request asked for, and because a person's export is one file
|
|
7492
|
+
* whether they used one product or three.
|
|
6978
7493
|
*/
|
|
6979
7494
|
const userExportSchema = z.object({
|
|
6980
7495
|
exportedAt: z.iso.datetime(),
|
|
6981
|
-
|
|
7496
|
+
users: z.array(userDetailSchema),
|
|
6982
7497
|
events: z.array(eventDtoSchema),
|
|
6983
7498
|
journeys: z.array(journeyRunDtoSchema),
|
|
6984
7499
|
deliveries: z.array(deliverySchema),
|
|
@@ -7930,7 +8445,9 @@ async function assertCowConfig(projectDir) {
|
|
|
7930
8445
|
} catch {
|
|
7931
8446
|
throw new Error(name === "cow.json" ? `No ${name} in "${projectDir}". Run \`cow init\` to create a project.` : `No ${name} in "${projectDir}". That file was selected with --config or COW_CONFIG.`);
|
|
7932
8447
|
}
|
|
7933
|
-
const
|
|
8448
|
+
const raw = JSON.parse(text);
|
|
8449
|
+
if (typeof raw === "object" && raw !== null && "project" in raw) throw new Error(`${name} names a "project", which Cowliss no longer has: a repository pushes to one app. Rename that field to "appId" and give it the app's id, which looks like "app_website" and is on the app's page in the dashboard.`);
|
|
8450
|
+
const parsed = cowConfigSchema.safeParse(raw);
|
|
7934
8451
|
if (!parsed.success) throw new Error(`${name} is invalid: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`);
|
|
7935
8452
|
return parsed.data;
|
|
7936
8453
|
}
|
|
@@ -7943,14 +8460,14 @@ async function readCowConfig(projectDir) {
|
|
|
7943
8460
|
}
|
|
7944
8461
|
}
|
|
7945
8462
|
/**
|
|
7946
|
-
* Which
|
|
8463
|
+
* Which app of the org this directory pushes to: the `appId` in its
|
|
7947
8464
|
* `cow.json`, or undefined when there is no `cow.json` here at all. The
|
|
7948
|
-
* undefined case is `cow pull` into an empty directory, which has no
|
|
7949
|
-
*
|
|
8465
|
+
* undefined case is `cow pull` into an empty directory, which has no app to
|
|
8466
|
+
* scope by yet and reads the org's pushes.
|
|
7950
8467
|
*/
|
|
7951
|
-
async function
|
|
8468
|
+
async function configuredApp(projectDir) {
|
|
7952
8469
|
try {
|
|
7953
|
-
return (await assertCowConfig(projectDir)).
|
|
8470
|
+
return (await assertCowConfig(projectDir)).appId;
|
|
7954
8471
|
} catch {
|
|
7955
8472
|
return;
|
|
7956
8473
|
}
|
|
@@ -7992,8 +8509,13 @@ async function buildProject(projectDir) {
|
|
|
7992
8509
|
const manifestJourneys = [];
|
|
7993
8510
|
const manifestTemplates = [];
|
|
7994
8511
|
for (const built of bundles) {
|
|
7995
|
-
|
|
7996
|
-
|
|
8512
|
+
let report;
|
|
8513
|
+
try {
|
|
8514
|
+
const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
|
|
8515
|
+
report = await runGuest(module, { kind: "manifest" });
|
|
8516
|
+
} catch (error) {
|
|
8517
|
+
throw new Error(`${built.source.relPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
8518
|
+
}
|
|
7997
8519
|
const isJourney = built.source.kind === "journeys";
|
|
7998
8520
|
if (report?.kind !== (isJourney ? "journey" : "template")) throw new Error(`${built.source.relPath} is not a ${isJourney ? "journey" : "template"}: a journey default-exports defineJourney({ ... }), a template exports a component and a zod \`props\` schema.`);
|
|
7999
8521
|
if (report.kind === "journey") manifestJourneys.push({
|
|
@@ -8001,6 +8523,8 @@ async function buildProject(projectDir) {
|
|
|
8001
8523
|
tags: report.tags,
|
|
8002
8524
|
trigger: report.trigger,
|
|
8003
8525
|
purpose: report.purpose,
|
|
8526
|
+
enrollment: report.enrollment,
|
|
8527
|
+
description: report.description,
|
|
8004
8528
|
from: report.from,
|
|
8005
8529
|
spine: readSpine(await readFile(built.source.file, "utf8")),
|
|
8006
8530
|
bundle: built.digest
|
|
@@ -8010,6 +8534,7 @@ async function buildProject(projectDir) {
|
|
|
8010
8534
|
tags: report.tags,
|
|
8011
8535
|
sendClass: report.sendClass,
|
|
8012
8536
|
verifyLink: report.verifyLink,
|
|
8537
|
+
unsubscribeLink: report.unsubscribeLink,
|
|
8013
8538
|
propsSchema: report.propsSchema,
|
|
8014
8539
|
bundle: built.digest
|
|
8015
8540
|
});
|
|
@@ -8213,7 +8738,7 @@ async function copyExample(name, dir, force) {
|
|
|
8213
8738
|
return files;
|
|
8214
8739
|
}
|
|
8215
8740
|
function registerAdd(program, io) {
|
|
8216
|
-
program.command("add").description("add gallery example code to a cow
|
|
8741
|
+
program.command("add").description("add gallery example code to a cow repo").command("example <name>").description("copy an example's journeys, emails, and scenarios into this repo (local: no API call)").option("--force", "overwrite files that already exist").action(async (name, opts) => {
|
|
8217
8742
|
const dir = process.cwd();
|
|
8218
8743
|
await assertCowConfig(dir);
|
|
8219
8744
|
const files = await copyExample(name, dir, opts.force === true);
|
|
@@ -8242,10 +8767,13 @@ async function login(env, options) {
|
|
|
8242
8767
|
const claims = decodeSessionToken(token);
|
|
8243
8768
|
if (claims === null) throw new Error("Token is not a decodable JWT");
|
|
8244
8769
|
if (claims.userId === null) throw new Error("Token has no subject claim. Is it a Clerk session token?");
|
|
8245
|
-
const
|
|
8246
|
-
if (options.apiFlag !== void 0) credentials.apiUrl = options.apiFlag;
|
|
8770
|
+
const apiUrl = options.apiFlag ?? resolveApiUrl(env, null, void 0, (await readCowConfig(process.cwd()))?.apiUrl);
|
|
8247
8771
|
return {
|
|
8248
|
-
credentialsPath: await writeCredentials(env,
|
|
8772
|
+
credentialsPath: await writeCredentials(env, {
|
|
8773
|
+
token,
|
|
8774
|
+
apiUrl
|
|
8775
|
+
}),
|
|
8776
|
+
apiUrl,
|
|
8249
8777
|
userId: claims.userId,
|
|
8250
8778
|
orgId: claims.orgId,
|
|
8251
8779
|
role: claims.role,
|
|
@@ -8332,7 +8860,7 @@ function openBrowser(url) {
|
|
|
8332
8860
|
//#region src/commands/auth.ts
|
|
8333
8861
|
/** login/logout/whoami: session-token management, no contract route. */
|
|
8334
8862
|
function registerAuth(program, env, io) {
|
|
8335
|
-
program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a
|
|
8863
|
+
program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in cow.json (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
|
|
8336
8864
|
const outcome = await login(env, {
|
|
8337
8865
|
token: opts.token,
|
|
8338
8866
|
webUrl: opts.web,
|
|
@@ -8362,8 +8890,14 @@ function registerAuth(program, env, io) {
|
|
|
8362
8890
|
}
|
|
8363
8891
|
const claims = decodeSessionToken(credentials?.token ?? "");
|
|
8364
8892
|
if (!claims) throw new Error("Cached token is not decodable. Run `cow login` again.");
|
|
8893
|
+
const projectApiUrl = (await readCowConfig(process.cwd()))?.apiUrl;
|
|
8894
|
+
const apiUrl = resolveApiUrl(env, credentials, opts.api, projectApiUrl);
|
|
8895
|
+
const mintedFor = credentials?.apiUrl;
|
|
8365
8896
|
emit({ data: {
|
|
8366
8897
|
credential: kind,
|
|
8898
|
+
apiUrl,
|
|
8899
|
+
mintedFor: mintedFor ?? null,
|
|
8900
|
+
...mintedFor !== void 0 && mintedFor !== apiUrl ? { warning: `This token was minted for ${mintedFor}, but commands here talk to ${apiUrl}. Run \`cow login\` again from this directory.` } : {},
|
|
8367
8901
|
...claims
|
|
8368
8902
|
} }, io, opts.json);
|
|
8369
8903
|
});
|
|
@@ -8376,7 +8910,7 @@ function registerAuth(program, env, io) {
|
|
|
8376
8910
|
* the current directory. Local only, no API call and no network.
|
|
8377
8911
|
*/
|
|
8378
8912
|
function registerBuild(program, io) {
|
|
8379
|
-
program.command("build").description("typecheck and bundle the
|
|
8913
|
+
program.command("build").description("typecheck and bundle the repo in the current directory, writing .cow/build (local: no API call)").action(async (opts) => {
|
|
8380
8914
|
emit({ data: await buildProject(process.cwd()) }, io, opts.json);
|
|
8381
8915
|
});
|
|
8382
8916
|
}
|
|
@@ -8809,7 +9343,8 @@ function htmlResponse(description) {
|
|
|
8809
9343
|
const params$11 = z.object({ id: z.string() });
|
|
8810
9344
|
/**
|
|
8811
9345
|
* An app is the attribution unit: what a developer names, what ingestion
|
|
8812
|
-
* stamps, and what
|
|
9346
|
+
* stamps, and what a profile, a segment and a journey belong to (ADR 0022).
|
|
9347
|
+
* It is also the push unit, which is what `status` reports on. Its inbound
|
|
8813
9348
|
* pipes live under /v1/apps/{appId}/sources, served by the sources module.
|
|
8814
9349
|
*/
|
|
8815
9350
|
const apps = defineModule(defineRoute({
|
|
@@ -8867,6 +9402,23 @@ const apps = defineModule(defineRoute({
|
|
|
8867
9402
|
...sessionErrors,
|
|
8868
9403
|
...errors("not_found", "validation_failed", "malformed_request")
|
|
8869
9404
|
}
|
|
9405
|
+
}), defineRoute({
|
|
9406
|
+
method: "get",
|
|
9407
|
+
path: "/v1/apps/{id}/status",
|
|
9408
|
+
operationId: "apps.status",
|
|
9409
|
+
tags: ["apps"],
|
|
9410
|
+
summary: "What an app's journeys and templates are doing",
|
|
9411
|
+
security: PIPELINE_AUTH,
|
|
9412
|
+
surfaces: {
|
|
9413
|
+
cli: false,
|
|
9414
|
+
mcpName: "status"
|
|
9415
|
+
},
|
|
9416
|
+
request: { params: params$11 },
|
|
9417
|
+
responses: {
|
|
9418
|
+
200: envelope(appStatusSchema),
|
|
9419
|
+
...sessionErrors,
|
|
9420
|
+
...errors("not_found", "validation_failed")
|
|
9421
|
+
}
|
|
8870
9422
|
}));
|
|
8871
9423
|
|
|
8872
9424
|
//#endregion
|
|
@@ -9158,7 +9710,7 @@ const catalog = defineModule(defineRoute({
|
|
|
9158
9710
|
//#region ../../packages/shared/src/contract/consent.ts
|
|
9159
9711
|
/**
|
|
9160
9712
|
* The consent purposes an organization's profiles answer: the two fixed ones
|
|
9161
|
-
* every org has, plus whatever its
|
|
9713
|
+
* every org has, plus whatever its apps declare in their `cow.json`.
|
|
9162
9714
|
*
|
|
9163
9715
|
* Read-only, and deliberately so. Purposes are code, like journeys and
|
|
9164
9716
|
* templates: a deploy upserts them and never deletes one, because profiles
|
|
@@ -9300,11 +9852,11 @@ const domains = defineModule(defineRoute({
|
|
|
9300
9852
|
path: "/v1/domains",
|
|
9301
9853
|
operationId: "domains.create",
|
|
9302
9854
|
tags: ["domains"],
|
|
9303
|
-
summary: "
|
|
9855
|
+
summary: "Add a sending domain",
|
|
9304
9856
|
security: SESSION_AUTH,
|
|
9305
9857
|
request: { body: jsonBody(createSenderDomainBodySchema) },
|
|
9306
9858
|
responses: {
|
|
9307
|
-
201: envelope(senderDomainSchema, "The
|
|
9859
|
+
201: envelope(senderDomainSchema, "The domain with the DNS records to publish, which is what claims it"),
|
|
9308
9860
|
...sessionErrors,
|
|
9309
9861
|
...errors("conflict", "validation_failed", "malformed_request", "dependency_unavailable")
|
|
9310
9862
|
}
|
|
@@ -9357,7 +9909,7 @@ const domains = defineModule(defineRoute({
|
|
|
9357
9909
|
path: "/v1/domains/{id}",
|
|
9358
9910
|
operationId: "domains.delete",
|
|
9359
9911
|
tags: ["domains"],
|
|
9360
|
-
summary: "Give up a domain
|
|
9912
|
+
summary: "Give up a sending domain",
|
|
9361
9913
|
security: SESSION_AUTH,
|
|
9362
9914
|
request: { params: params$9 },
|
|
9363
9915
|
responses: {
|
|
@@ -9369,8 +9921,31 @@ const domains = defineModule(defineRoute({
|
|
|
9369
9921
|
|
|
9370
9922
|
//#endregion
|
|
9371
9923
|
//#region ../../packages/shared/src/contract/emails.ts
|
|
9372
|
-
/**
|
|
9924
|
+
/**
|
|
9925
|
+
* The transactional send a developer's own code makes, plus the public
|
|
9926
|
+
* email-verification pages.
|
|
9927
|
+
*
|
|
9928
|
+
* `emails.send` is the first-party shape of the same send the Resend facade
|
|
9929
|
+
* serves (ADR 0014): one service, two wire formats, and this one answers in
|
|
9930
|
+
* the envelope with our error codes. It authenticates with an org API key
|
|
9931
|
+
* like the rest of the write path, which is why it is hidden from the CLI
|
|
9932
|
+
* and MCP surfaces: those hold a session.
|
|
9933
|
+
*/
|
|
9373
9934
|
const emails = defineModule(defineRoute({
|
|
9935
|
+
method: "post",
|
|
9936
|
+
path: "/v1/emails",
|
|
9937
|
+
operationId: "emails.send",
|
|
9938
|
+
tags: ["emails"],
|
|
9939
|
+
summary: "Send one transactional email",
|
|
9940
|
+
security: API_KEY_AUTH,
|
|
9941
|
+
surfaces: HIDDEN_FROM_TOOLS,
|
|
9942
|
+
request: { body: jsonBody(sendEmailBodySchema) },
|
|
9943
|
+
responses: {
|
|
9944
|
+
201: envelope(sendEmailResultSchema, "Handed over; the delivery's id"),
|
|
9945
|
+
...ingestionErrors,
|
|
9946
|
+
...errors("forbidden", "not_found", "conflict")
|
|
9947
|
+
}
|
|
9948
|
+
}), defineRoute({
|
|
9374
9949
|
method: "get",
|
|
9375
9950
|
path: "/v1/public/verify",
|
|
9376
9951
|
operationId: "emails.verifyPage",
|
|
@@ -9644,7 +10219,7 @@ const journeys = defineModule(defineRoute({
|
|
|
9644
10219
|
security: SESSION_AUTH,
|
|
9645
10220
|
request: { params: params$7 },
|
|
9646
10221
|
responses: {
|
|
9647
|
-
200: envelope(
|
|
10222
|
+
200: envelope(journeyDetailSchema),
|
|
9648
10223
|
...sessionErrors,
|
|
9649
10224
|
...errors("not_found")
|
|
9650
10225
|
}
|
|
@@ -9663,31 +10238,33 @@ const journeys = defineModule(defineRoute({
|
|
|
9663
10238
|
}
|
|
9664
10239
|
}), defineRoute({
|
|
9665
10240
|
method: "post",
|
|
9666
|
-
path: "/v1/journeys/
|
|
9667
|
-
operationId: "journeys.
|
|
10241
|
+
path: "/v1/journeys/status",
|
|
10242
|
+
operationId: "journeys.setStatus",
|
|
9668
10243
|
tags: ["journeys"],
|
|
9669
|
-
summary: "
|
|
10244
|
+
summary: "Put journeys on, off or on hold",
|
|
9670
10245
|
security: PIPELINE_AUTH,
|
|
9671
10246
|
surfaces: { cli: false },
|
|
9672
|
-
request: { body: jsonBody(
|
|
10247
|
+
request: { body: jsonBody(setJourneysStatusBodySchema) },
|
|
9673
10248
|
responses: {
|
|
9674
|
-
200: envelope(z.array(
|
|
10249
|
+
200: envelope(z.array(journeyWithOfferSchema), "The journeys as they now stand, each with the members turning it on has waiting"),
|
|
9675
10250
|
...sessionErrors,
|
|
9676
|
-
...errors("not_found", "
|
|
10251
|
+
...errors("not_found", "validation_failed", "malformed_request")
|
|
9677
10252
|
}
|
|
9678
10253
|
}), defineRoute({
|
|
9679
|
-
method: "
|
|
9680
|
-
path: "/v1/journeys/
|
|
9681
|
-
operationId: "journeys.
|
|
10254
|
+
method: "get",
|
|
10255
|
+
path: "/v1/journeys/{key}/waits",
|
|
10256
|
+
operationId: "journeys.waitsDue",
|
|
9682
10257
|
tags: ["journeys"],
|
|
9683
|
-
summary: "
|
|
9684
|
-
security:
|
|
9685
|
-
|
|
9686
|
-
|
|
10258
|
+
summary: "Count the executions a hold until a given day would end",
|
|
10259
|
+
security: SESSION_AUTH,
|
|
10260
|
+
request: {
|
|
10261
|
+
params: params$7,
|
|
10262
|
+
query: waitsDueQuerySchema
|
|
10263
|
+
},
|
|
9687
10264
|
responses: {
|
|
9688
|
-
200: envelope(
|
|
10265
|
+
200: envelope(waitsDueSchema, "Executions in flight, and how many of them come due before that instant"),
|
|
9689
10266
|
...sessionErrors,
|
|
9690
|
-
...errors("not_found", "
|
|
10267
|
+
...errors("not_found", "validation_failed")
|
|
9691
10268
|
}
|
|
9692
10269
|
}), defineRoute({
|
|
9693
10270
|
method: "delete",
|
|
@@ -9702,6 +10279,59 @@ const journeys = defineModule(defineRoute({
|
|
|
9702
10279
|
...sessionErrors,
|
|
9703
10280
|
...errors("not_found")
|
|
9704
10281
|
}
|
|
10282
|
+
}), defineRoute({
|
|
10283
|
+
method: "get",
|
|
10284
|
+
path: "/v1/journeys/{key}/enrollment/preview",
|
|
10285
|
+
operationId: "journeys.enrollmentPreview",
|
|
10286
|
+
tags: ["journeys"],
|
|
10287
|
+
summary: "Count who turning this journey on would reach",
|
|
10288
|
+
security: SESSION_AUTH,
|
|
10289
|
+
request: { params: params$7 },
|
|
10290
|
+
responses: {
|
|
10291
|
+
200: envelope(enrollmentCountsSchema, "What enrolling the journey's current members would do"),
|
|
10292
|
+
...sessionErrors,
|
|
10293
|
+
...errors("not_found", "dependency_unavailable")
|
|
10294
|
+
}
|
|
10295
|
+
}), defineRoute({
|
|
10296
|
+
method: "post",
|
|
10297
|
+
path: "/v1/journeys/{key}/enrollment",
|
|
10298
|
+
operationId: "journeys.startEnrollment",
|
|
10299
|
+
tags: ["journeys"],
|
|
10300
|
+
summary: "Enroll the journey's current members",
|
|
10301
|
+
security: SESSION_AUTH,
|
|
10302
|
+
surfaces: HIDDEN_FROM_TOOLS,
|
|
10303
|
+
request: { params: params$7 },
|
|
10304
|
+
responses: {
|
|
10305
|
+
202: envelope(enrollmentStatusSchema, "The job, as it starts"),
|
|
10306
|
+
...sessionErrors,
|
|
10307
|
+
...errors("not_found")
|
|
10308
|
+
}
|
|
10309
|
+
}), defineRoute({
|
|
10310
|
+
method: "get",
|
|
10311
|
+
path: "/v1/journeys/{key}/enrollment",
|
|
10312
|
+
operationId: "journeys.enrollmentStatus",
|
|
10313
|
+
tags: ["journeys"],
|
|
10314
|
+
summary: "How far the journey's enrollment has got",
|
|
10315
|
+
security: SESSION_AUTH,
|
|
10316
|
+
request: { params: params$7 },
|
|
10317
|
+
responses: {
|
|
10318
|
+
200: envelope(enrollmentStatusSchema),
|
|
10319
|
+
...sessionErrors,
|
|
10320
|
+
...errors("not_found")
|
|
10321
|
+
}
|
|
10322
|
+
}), defineRoute({
|
|
10323
|
+
method: "post",
|
|
10324
|
+
path: "/v1/journeys/{key}/enrollment/cancel",
|
|
10325
|
+
operationId: "journeys.cancelEnrollment",
|
|
10326
|
+
tags: ["journeys"],
|
|
10327
|
+
summary: "Stop a running enrollment",
|
|
10328
|
+
security: SESSION_AUTH,
|
|
10329
|
+
request: { params: params$7 },
|
|
10330
|
+
responses: {
|
|
10331
|
+
200: envelope(enrollmentStatusSchema, "The job as it stood when the stop was asked for"),
|
|
10332
|
+
...sessionErrors,
|
|
10333
|
+
...errors("not_found")
|
|
10334
|
+
}
|
|
9705
10335
|
}), defineRoute({
|
|
9706
10336
|
method: "post",
|
|
9707
10337
|
path: "/v1/journeys/{key}/dry-run",
|
|
@@ -9769,63 +10399,11 @@ const me = defineModule(defineRoute({
|
|
|
9769
10399
|
}
|
|
9770
10400
|
}));
|
|
9771
10401
|
|
|
9772
|
-
//#endregion
|
|
9773
|
-
//#region ../../packages/shared/src/contract/project.ts
|
|
9774
|
-
/**
|
|
9775
|
-
* The org's project. Singular in the path on purpose: a caller always means
|
|
9776
|
-
* one, the one its `cow.json` names, so the name is a query parameter and
|
|
9777
|
-
* there is no id to put in a URL. An org with a single project needs neither.
|
|
9778
|
-
*/
|
|
9779
|
-
const project = defineModule(defineRoute({
|
|
9780
|
-
method: "get",
|
|
9781
|
-
path: "/v1/project",
|
|
9782
|
-
operationId: "project.get",
|
|
9783
|
-
tags: ["project"],
|
|
9784
|
-
summary: "Get one of the organization's projects",
|
|
9785
|
-
security: PIPELINE_AUTH,
|
|
9786
|
-
request: { query: getProjectQuerySchema },
|
|
9787
|
-
responses: {
|
|
9788
|
-
200: envelope(projectSchema),
|
|
9789
|
-
...sessionErrors,
|
|
9790
|
-
...errors("project_missing", "validation_failed", "malformed_request")
|
|
9791
|
-
}
|
|
9792
|
-
}), defineRoute({
|
|
9793
|
-
method: "get",
|
|
9794
|
-
path: "/v1/project/status",
|
|
9795
|
-
operationId: "project.status",
|
|
9796
|
-
tags: ["project"],
|
|
9797
|
-
summary: "What a project's journeys and templates are doing",
|
|
9798
|
-
security: PIPELINE_AUTH,
|
|
9799
|
-
surfaces: {
|
|
9800
|
-
cli: false,
|
|
9801
|
-
mcpName: "status"
|
|
9802
|
-
},
|
|
9803
|
-
request: { query: getProjectQuerySchema },
|
|
9804
|
-
responses: {
|
|
9805
|
-
200: envelope(projectStatusSchema),
|
|
9806
|
-
...sessionErrors,
|
|
9807
|
-
...errors("project_missing", "validation_failed", "malformed_request")
|
|
9808
|
-
}
|
|
9809
|
-
}), defineRoute({
|
|
9810
|
-
method: "post",
|
|
9811
|
-
path: "/v1/project",
|
|
9812
|
-
operationId: "project.create",
|
|
9813
|
-
tags: ["project"],
|
|
9814
|
-
summary: "Create a project in the organization",
|
|
9815
|
-
security: PIPELINE_AUTH,
|
|
9816
|
-
request: { body: jsonBody(createProjectBodySchema) },
|
|
9817
|
-
responses: {
|
|
9818
|
-
201: envelope(projectSchema, "The created project"),
|
|
9819
|
-
...sessionErrors,
|
|
9820
|
-
...errors("project_exists", "validation_failed", "malformed_request")
|
|
9821
|
-
}
|
|
9822
|
-
}));
|
|
9823
|
-
|
|
9824
10402
|
//#endregion
|
|
9825
10403
|
//#region ../../packages/shared/src/contract/pushes.ts
|
|
9826
10404
|
const params$6 = z.object({ id: z.string() });
|
|
9827
10405
|
/**
|
|
9828
|
-
* Pushes: one `cow push` each, the whole
|
|
10406
|
+
* Pushes: one `cow push` each, the whole app at one point in time. The
|
|
9829
10407
|
* push stores the source archive and its compile creates a version of every
|
|
9830
10408
|
* key whose module changed; it turns nothing on (ADR 0011).
|
|
9831
10409
|
*
|
|
@@ -9839,14 +10417,14 @@ const pushes = defineModule(defineRoute({
|
|
|
9839
10417
|
path: "/v1/pushes",
|
|
9840
10418
|
operationId: "pushes.create",
|
|
9841
10419
|
tags: ["pushes"],
|
|
9842
|
-
summary: "Push a built
|
|
10420
|
+
summary: "Push a built app",
|
|
9843
10421
|
security: PIPELINE_AUTH,
|
|
9844
10422
|
surfaces: HIDDEN_FROM_TOOLS,
|
|
9845
10423
|
request: { body: jsonBody(createPushBodySchema) },
|
|
9846
10424
|
responses: {
|
|
9847
10425
|
201: envelope(pushSchema, "The stored push and the versions it is compiling"),
|
|
9848
10426
|
...sessionErrors,
|
|
9849
|
-
...errors("
|
|
10427
|
+
...errors("push_invalid", "validation_failed", "malformed_request", "dependency_unavailable")
|
|
9850
10428
|
}
|
|
9851
10429
|
}), defineRoute({
|
|
9852
10430
|
method: "get",
|
|
@@ -9897,8 +10475,8 @@ const pushes = defineModule(defineRoute({
|
|
|
9897
10475
|
//#region ../../packages/shared/src/contract/resend.ts
|
|
9898
10476
|
/**
|
|
9899
10477
|
* The Resend-compatible send facade (ADR 0014): a developer points
|
|
9900
|
-
* `RESEND_BASE_URL` at `/resend/{
|
|
9901
|
-
*
|
|
10478
|
+
* `RESEND_BASE_URL` at `/resend/{appOrSourceId}` and keeps their code. The
|
|
10479
|
+
* path segment names the app, because the Resend SDK carries no other field
|
|
9902
10480
|
* and accepts no custom header; the bearer is the organization's ingestion
|
|
9903
10481
|
* key.
|
|
9904
10482
|
*
|
|
@@ -9914,7 +10492,15 @@ const FACADE_SURFACES = {
|
|
|
9914
10492
|
mcp: false,
|
|
9915
10493
|
docs: false
|
|
9916
10494
|
};
|
|
9917
|
-
|
|
10495
|
+
/**
|
|
10496
|
+
* The app the message belongs to: its own id, or the id of its one `api`
|
|
10497
|
+
* source, which is what every base URL held before app ids were accepted
|
|
10498
|
+
* here. Both resolve to that source, so attribution is the same either way.
|
|
10499
|
+
*/
|
|
10500
|
+
const targetParam = z.object({ appOrSourceId: z.union([appIdSchema, sourceIdSchema], { error: "Use an app id (\"app_website\") or the id of its API source." }).openapi({
|
|
10501
|
+
example: "app_website",
|
|
10502
|
+
description: "The app to send for, or the id of its API source. Both name the same pipe."
|
|
10503
|
+
}) });
|
|
9918
10504
|
/** Resend's error body, for the answers this facade gives. */
|
|
9919
10505
|
const facadeError = (description) => ({
|
|
9920
10506
|
description,
|
|
@@ -9922,21 +10508,21 @@ const facadeError = (description) => ({
|
|
|
9922
10508
|
});
|
|
9923
10509
|
const facadeErrors = {
|
|
9924
10510
|
401: facadeError("missing_api_key | invalid_api_key"),
|
|
9925
|
-
404: facadeError("not_found: unknown source, or unknown message"),
|
|
10511
|
+
404: facadeError("not_found: unknown app or source, or unknown message"),
|
|
9926
10512
|
422: facadeError("validation_error | missing_required_field"),
|
|
9927
10513
|
429: facadeError("monthly_quota_exceeded | rate_limit_exceeded"),
|
|
9928
10514
|
500: facadeError("application_error")
|
|
9929
10515
|
};
|
|
9930
10516
|
const resend = defineModule(defineRoute({
|
|
9931
10517
|
method: "post",
|
|
9932
|
-
path: "/resend/{
|
|
10518
|
+
path: "/resend/{appOrSourceId}/emails",
|
|
9933
10519
|
operationId: "resend.send",
|
|
9934
10520
|
tags: ["resend"],
|
|
9935
10521
|
summary: "Send one email (Resend-compatible)",
|
|
9936
10522
|
security: API_KEY_AUTH,
|
|
9937
10523
|
surfaces: FACADE_SURFACES,
|
|
9938
10524
|
request: {
|
|
9939
|
-
params:
|
|
10525
|
+
params: targetParam,
|
|
9940
10526
|
body: jsonBody(resendSendBodySchema)
|
|
9941
10527
|
},
|
|
9942
10528
|
responses: {
|
|
@@ -9949,14 +10535,14 @@ const resend = defineModule(defineRoute({
|
|
|
9949
10535
|
}
|
|
9950
10536
|
}), defineRoute({
|
|
9951
10537
|
method: "post",
|
|
9952
|
-
path: "/resend/{
|
|
10538
|
+
path: "/resend/{appOrSourceId}/emails/batch",
|
|
9953
10539
|
operationId: "resend.sendBatch",
|
|
9954
10540
|
tags: ["resend"],
|
|
9955
10541
|
summary: "Send up to 100 emails (Resend-compatible)",
|
|
9956
10542
|
security: API_KEY_AUTH,
|
|
9957
10543
|
surfaces: FACADE_SURFACES,
|
|
9958
10544
|
request: {
|
|
9959
|
-
params:
|
|
10545
|
+
params: targetParam,
|
|
9960
10546
|
body: jsonBody(resendBatchBodySchema)
|
|
9961
10547
|
},
|
|
9962
10548
|
responses: {
|
|
@@ -9969,13 +10555,13 @@ const resend = defineModule(defineRoute({
|
|
|
9969
10555
|
}
|
|
9970
10556
|
}), defineRoute({
|
|
9971
10557
|
method: "get",
|
|
9972
|
-
path: "/resend/{
|
|
10558
|
+
path: "/resend/{appOrSourceId}/emails/{id}",
|
|
9973
10559
|
operationId: "resend.get",
|
|
9974
10560
|
tags: ["resend"],
|
|
9975
10561
|
summary: "Fetch one sent email (Resend-compatible)",
|
|
9976
10562
|
security: API_KEY_AUTH,
|
|
9977
10563
|
surfaces: FACADE_SURFACES,
|
|
9978
|
-
request: { params:
|
|
10564
|
+
request: { params: targetParam.extend({ id: z.string().openapi({ example: "dlv_2f9a8c1b" }) }) },
|
|
9979
10565
|
responses: {
|
|
9980
10566
|
200: {
|
|
9981
10567
|
description: "The email object",
|
|
@@ -10080,7 +10666,7 @@ const segments = defineModule(defineRoute({
|
|
|
10080
10666
|
responses: {
|
|
10081
10667
|
200: envelope(segmentDetailSchema),
|
|
10082
10668
|
...sessionErrors,
|
|
10083
|
-
...errors("not_found"),
|
|
10669
|
+
...errors("not_found", "conflict"),
|
|
10084
10670
|
...writeErrors
|
|
10085
10671
|
}
|
|
10086
10672
|
}), defineRoute({
|
|
@@ -10094,7 +10680,7 @@ const segments = defineModule(defineRoute({
|
|
|
10094
10680
|
responses: {
|
|
10095
10681
|
200: envelope(deletedSchema),
|
|
10096
10682
|
...sessionErrors,
|
|
10097
|
-
...errors("not_found")
|
|
10683
|
+
...errors("not_found", "conflict")
|
|
10098
10684
|
}
|
|
10099
10685
|
}), defineRoute({
|
|
10100
10686
|
method: "get",
|
|
@@ -10462,8 +11048,18 @@ const mergedRedirect = { 307: {
|
|
|
10462
11048
|
headers: z.object({ Location: z.string().meta({ description: "The survivor's URL" }) })
|
|
10463
11049
|
} };
|
|
10464
11050
|
/**
|
|
10465
|
-
* Profiles by their Cowliss-generated id. `find`
|
|
10466
|
-
* the literal
|
|
11051
|
+
* Profiles by their Cowliss-generated id. `find`, `export` and `erase`
|
|
11052
|
+
* precede `{profileId}` so the literal paths win.
|
|
11053
|
+
*
|
|
11054
|
+
* Compliance has two ways in (ADR 0022). A `usr_` id names one profile, and
|
|
11055
|
+
* erasure or export takes it with everything merged into it. A
|
|
11056
|
+
* `{ kind, value }` identifier names a person, which is one profile per app
|
|
11057
|
+
* that knows the value, and the same erasure or export runs over all of
|
|
11058
|
+
* them at once: the addressing differs, nothing below it does. An identifier
|
|
11059
|
+
* never travels in the URL, neither in the path nor in a query string,
|
|
11060
|
+
* because a GDPR request carries an address and a URL is logged whole. That
|
|
11061
|
+
* is why both identifier routes are a POST carrying a body, including the
|
|
11062
|
+
* export, which reads.
|
|
10467
11063
|
*/
|
|
10468
11064
|
const users = defineModule(defineRoute({
|
|
10469
11065
|
method: "get",
|
|
@@ -10491,6 +11087,32 @@ const users = defineModule(defineRoute({
|
|
|
10491
11087
|
...sessionErrors,
|
|
10492
11088
|
...errors("not_found", "validation_failed")
|
|
10493
11089
|
}
|
|
11090
|
+
}), defineRoute({
|
|
11091
|
+
method: "post",
|
|
11092
|
+
path: "/v1/users/export",
|
|
11093
|
+
operationId: "users.exportByIdentifier",
|
|
11094
|
+
tags: ["users"],
|
|
11095
|
+
summary: "Export everything held on the people one identifier names",
|
|
11096
|
+
security: SESSION_AUTH,
|
|
11097
|
+
request: { body: jsonBody(exportUserByIdentifierBodySchema) },
|
|
11098
|
+
responses: {
|
|
11099
|
+
200: envelope(userExportSchema),
|
|
11100
|
+
...sessionErrors,
|
|
11101
|
+
...errors("not_found", "validation_failed", "malformed_request")
|
|
11102
|
+
}
|
|
11103
|
+
}), defineRoute({
|
|
11104
|
+
method: "post",
|
|
11105
|
+
path: "/v1/users/erase",
|
|
11106
|
+
operationId: "users.eraseByIdentifier",
|
|
11107
|
+
tags: ["users"],
|
|
11108
|
+
summary: "Erase every person one identifier names (GDPR)",
|
|
11109
|
+
security: SESSION_AUTH,
|
|
11110
|
+
request: { body: jsonBody(eraseUserByIdentifierBodySchema) },
|
|
11111
|
+
responses: {
|
|
11112
|
+
200: envelope(eraseUserResponseSchema),
|
|
11113
|
+
...sessionErrors,
|
|
11114
|
+
...errors("not_found", "validation_failed", "malformed_request")
|
|
11115
|
+
}
|
|
10494
11116
|
}), defineRoute({
|
|
10495
11117
|
method: "get",
|
|
10496
11118
|
path: "/v1/users/{profileId}",
|
|
@@ -10567,6 +11189,23 @@ const users = defineModule(defineRoute({
|
|
|
10567
11189
|
...sessionErrors,
|
|
10568
11190
|
...errors("not_found", "validation_failed")
|
|
10569
11191
|
}
|
|
11192
|
+
}), defineRoute({
|
|
11193
|
+
method: "get",
|
|
11194
|
+
path: "/v1/users/{profileId}/runs",
|
|
11195
|
+
operationId: "users.listRuns",
|
|
11196
|
+
tags: ["users"],
|
|
11197
|
+
summary: "The journeys a profile has completed",
|
|
11198
|
+
security: SESSION_AUTH,
|
|
11199
|
+
request: {
|
|
11200
|
+
params: params$2,
|
|
11201
|
+
query: listUserRunsQuerySchema
|
|
11202
|
+
},
|
|
11203
|
+
responses: {
|
|
11204
|
+
200: list(userRunSchema),
|
|
11205
|
+
...mergedRedirect,
|
|
11206
|
+
...sessionErrors,
|
|
11207
|
+
...errors("not_found", "validation_failed")
|
|
11208
|
+
}
|
|
10570
11209
|
}));
|
|
10571
11210
|
|
|
10572
11211
|
//#endregion
|
|
@@ -10737,7 +11376,6 @@ const contract = {
|
|
|
10737
11376
|
violations,
|
|
10738
11377
|
review,
|
|
10739
11378
|
segments,
|
|
10740
|
-
project,
|
|
10741
11379
|
artifacts,
|
|
10742
11380
|
pushes,
|
|
10743
11381
|
versions,
|
|
@@ -11120,47 +11758,84 @@ function registerContractCommands(program, run) {
|
|
|
11120
11758
|
//#endregion
|
|
11121
11759
|
//#region src/commands/enable.ts
|
|
11122
11760
|
/**
|
|
11123
|
-
* `cow enable` and `cow
|
|
11124
|
-
* 0013). Hand-written rather than derived from
|
|
11125
|
-
* the commands are top-level names a developer
|
|
11126
|
-
* takes the same set of keys they do.
|
|
11761
|
+
* `cow enable`, `cow disable` and `cow pause`: the one gate on a journey
|
|
11762
|
+
* (ADR 0011, ADR 0013, ADR 0018). Hand-written rather than derived from
|
|
11763
|
+
* `journeys.setStatus`, because the commands are top-level names a developer
|
|
11764
|
+
* types; the route itself takes the same set of keys they do.
|
|
11765
|
+
*
|
|
11766
|
+
* `cow enable` on a journey on hold is how it resumes: the server has one
|
|
11767
|
+
* transition to `on` and no second word for it.
|
|
11127
11768
|
*/
|
|
11128
11769
|
/**
|
|
11129
11770
|
* One call, whichever way the keys were named: the route takes the set, and
|
|
11130
|
-
* `--all` is the
|
|
11771
|
+
* `--all` is the app id, resolved on the server against the rows the app
|
|
11131
11772
|
* owns (so it reaches a key whose file the tree lost). A key that is not
|
|
11132
|
-
* there refuses the whole call, and nothing
|
|
11133
|
-
*/
|
|
11134
|
-
async function
|
|
11135
|
-
|
|
11136
|
-
|
|
11773
|
+
* there refuses the whole call, and nothing changes.
|
|
11774
|
+
*/
|
|
11775
|
+
async function setStatus(client, selector, status) {
|
|
11776
|
+
return (await client.request(contract.journeys["journeys.setStatus"], { body: {
|
|
11777
|
+
...selector,
|
|
11778
|
+
status
|
|
11779
|
+
} })).data;
|
|
11780
|
+
}
|
|
11781
|
+
/**
|
|
11782
|
+
* Who is already standing in a journey that was just turned on, and where
|
|
11783
|
+
* to let them in (ADR 0015). Printed, never acted on: enrolling a cohort
|
|
11784
|
+
* spends real money and reaches real people, so it stays a person's click
|
|
11785
|
+
* in the dashboard and no command here starts one.
|
|
11786
|
+
*/
|
|
11787
|
+
function segmentLines(journey) {
|
|
11788
|
+
const offer = journey.enrollment;
|
|
11789
|
+
if (!offer) return [];
|
|
11790
|
+
if (!offer.counts) return [` Who is in its segment could not be counted just now: ${offer.url}`];
|
|
11791
|
+
const { scanned, enrolled, skipped } = offer.counts;
|
|
11792
|
+
if (scanned === 0) return [" Nobody is in its segment yet."];
|
|
11793
|
+
return [
|
|
11794
|
+
` ${scanned} in its segment now, ${enrolled} of them can enter it now.`,
|
|
11795
|
+
...Object.entries(ENROLLMENT_SKIP_WORDS).map(([key, words]) => ({
|
|
11796
|
+
value: skipped[key],
|
|
11797
|
+
words
|
|
11798
|
+
})).filter(({ value }) => value > 0).map(({ value, words }) => ` ${value} ${words.reason}.`),
|
|
11799
|
+
...enrolled > 0 ? [` To let those ${enrolled} in: ${offer.url}`] : []
|
|
11800
|
+
];
|
|
11137
11801
|
}
|
|
11802
|
+
/** How the terminal names each state. */
|
|
11803
|
+
const STATE_WORD = {
|
|
11804
|
+
on: "on",
|
|
11805
|
+
off: "off",
|
|
11806
|
+
paused: "on hold"
|
|
11807
|
+
};
|
|
11138
11808
|
/** What the terminal says: one line per key, naming its new state. */
|
|
11139
|
-
function flipSummary(journeys,
|
|
11140
|
-
if (journeys.length === 0) return `This
|
|
11141
|
-
return journeys.map((journey) => `${journey.key} is ${
|
|
11809
|
+
function flipSummary(journeys, status) {
|
|
11810
|
+
if (journeys.length === 0) return `This app has no journeys to put ${STATE_WORD[status]}.`;
|
|
11811
|
+
return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
|
|
11142
11812
|
}
|
|
11143
|
-
|
|
11144
|
-
|
|
11145
|
-
|
|
11813
|
+
const DESCRIPTIONS = {
|
|
11814
|
+
on: "turn journeys on",
|
|
11815
|
+
off: "turn journeys off",
|
|
11816
|
+
paused: "hold journeys: nobody enters, and everyone part-way through waits"
|
|
11817
|
+
};
|
|
11818
|
+
function register(program, clientFor, io, verb, status) {
|
|
11819
|
+
program.command(verb).description(`${DESCRIPTIONS[status]} (many keys, or --all for this app's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this app").action(async (keys, opts) => {
|
|
11146
11820
|
const merged = {
|
|
11147
11821
|
...program.opts(),
|
|
11148
11822
|
...opts
|
|
11149
11823
|
};
|
|
11150
11824
|
if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
|
|
11151
11825
|
if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
|
|
11152
|
-
const selector = merged.all === true ? {
|
|
11153
|
-
const updated = await
|
|
11826
|
+
const selector = merged.all === true ? { appId: (await assertCowConfig(process.cwd())).appId } : { keys };
|
|
11827
|
+
const updated = await setStatus(await clientFor(merged), selector, status);
|
|
11154
11828
|
if (merged.json === true) {
|
|
11155
11829
|
emit({ data: updated }, io, true);
|
|
11156
11830
|
return;
|
|
11157
11831
|
}
|
|
11158
|
-
io.stdout(`${flipSummary(updated,
|
|
11832
|
+
io.stdout(`${flipSummary(updated, status)}\n`);
|
|
11159
11833
|
});
|
|
11160
11834
|
}
|
|
11161
11835
|
function registerEnable(program, clientFor, io) {
|
|
11162
|
-
register(program, clientFor, io,
|
|
11163
|
-
register(program, clientFor, io,
|
|
11836
|
+
register(program, clientFor, io, "enable", "on");
|
|
11837
|
+
register(program, clientFor, io, "disable", "off");
|
|
11838
|
+
register(program, clientFor, io, "pause", "paused");
|
|
11164
11839
|
}
|
|
11165
11840
|
|
|
11166
11841
|
//#endregion
|
|
@@ -11208,43 +11883,45 @@ async function packageJson(name) {
|
|
|
11208
11883
|
}
|
|
11209
11884
|
});
|
|
11210
11885
|
}
|
|
11886
|
+
/** The org's active apps, or null when this machine cannot read them. */
|
|
11887
|
+
async function listApps(client) {
|
|
11888
|
+
try {
|
|
11889
|
+
return (await client.request(contract.apps["apps.list"], { query: {
|
|
11890
|
+
status: "active",
|
|
11891
|
+
limit: 100
|
|
11892
|
+
} })).data.map((app) => app.id);
|
|
11893
|
+
} catch {
|
|
11894
|
+
return null;
|
|
11895
|
+
}
|
|
11896
|
+
}
|
|
11211
11897
|
/**
|
|
11212
|
-
*
|
|
11213
|
-
*
|
|
11214
|
-
*
|
|
11215
|
-
*
|
|
11216
|
-
* ponytail: plain fetch because `POST /v1/project` is not a contract route
|
|
11217
|
-
* yet; swap it for the typed client's `project.create` once ticket 04 lands.
|
|
11898
|
+
* Pick the app this repository pushes to. One app is the answer; several
|
|
11899
|
+
* are a question; none is a refusal, because the answer is in the dashboard
|
|
11900
|
+
* and `cow init` is not allowed to invent one.
|
|
11218
11901
|
*/
|
|
11219
|
-
async function
|
|
11220
|
-
if (
|
|
11221
|
-
|
|
11222
|
-
|
|
11902
|
+
async function chooseApp(client, webUrl, flag, org, io, skipPrompts) {
|
|
11903
|
+
if (flag) return { appId: flag };
|
|
11904
|
+
if (org.claimed !== null && org.claimed !== org.requested) return {
|
|
11905
|
+
appId: "",
|
|
11906
|
+
reason: `this machine is logged in to ${org.claimed}, not ${org.requested}`
|
|
11223
11907
|
};
|
|
11224
|
-
|
|
11225
|
-
|
|
11226
|
-
|
|
11227
|
-
|
|
11228
|
-
|
|
11229
|
-
|
|
11230
|
-
|
|
11231
|
-
|
|
11232
|
-
|
|
11233
|
-
|
|
11234
|
-
|
|
11235
|
-
|
|
11236
|
-
|
|
11237
|
-
|
|
11238
|
-
};
|
|
11239
|
-
} catch (error) {
|
|
11240
|
-
return {
|
|
11241
|
-
project: "skipped",
|
|
11242
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
11243
|
-
};
|
|
11244
|
-
}
|
|
11908
|
+
const apps = await listApps(client);
|
|
11909
|
+
if (apps === null) return {
|
|
11910
|
+
appId: "",
|
|
11911
|
+
reason: "this machine could not read the org's apps"
|
|
11912
|
+
};
|
|
11913
|
+
if (apps.length === 0) throw new Error(`This organization has no app yet, and a repository pushes to one. Create one at ${webUrl}/apps, then run \`cow init\` again.`);
|
|
11914
|
+
const only = apps[0];
|
|
11915
|
+
if (apps.length === 1 && only) return { appId: only };
|
|
11916
|
+
if (skipPrompts || !io.isTTY) return {
|
|
11917
|
+
appId: "",
|
|
11918
|
+
reason: `this organization has several apps (${apps.join(", ")}) and nothing said which`
|
|
11919
|
+
};
|
|
11920
|
+
io.stderr(`Apps in this organization:\n${apps.map((id) => ` ${id}`).join("\n")}\n`);
|
|
11921
|
+
return { appId: await ask("App id", only ?? "", io, false) };
|
|
11245
11922
|
}
|
|
11246
|
-
function registerInit(program, env, io) {
|
|
11247
|
-
program.command("init").argument("[dir]", "directory to create the
|
|
11923
|
+
function registerInit(program, clientFor, env, io) {
|
|
11924
|
+
program.command("init").argument("[dir]", "directory to create the repo in (default: here)").description("create a cow repo: cow.json, journeys/, emails/, and the files to build them").option("--org <id>", "organization id (default: the logged-in org)").option("--app <id>", "app id this repository pushes to (default: ask, or the org's only one)").option("--yes", "accept the defaults instead of prompting").option("--example <name>", "start from a gallery example").action(async (dirArg, opts) => {
|
|
11248
11925
|
const merged = {
|
|
11249
11926
|
...program.opts(),
|
|
11250
11927
|
...opts
|
|
@@ -11252,17 +11929,27 @@ function registerInit(program, env, io) {
|
|
|
11252
11929
|
const skipPrompts = opts.yes === true;
|
|
11253
11930
|
const cwd = process.cwd();
|
|
11254
11931
|
const defaultName = basename(dirArg ? resolve(cwd, dirArg) : cwd);
|
|
11255
|
-
const name = await ask("
|
|
11932
|
+
const name = await ask("Repo name", defaultName, io, skipPrompts);
|
|
11256
11933
|
const dir = dirArg ? resolve(cwd, dirArg) : name === defaultName ? cwd : resolve(cwd, name);
|
|
11257
11934
|
if (existsSync(join(dir, "cow.json"))) throw new Error(`"${dir}" already holds a cow.json. Use \`cow add example\` to add code to it.`);
|
|
11258
11935
|
const credentials = await readCredentials(env);
|
|
11259
11936
|
const claimedOrg = credentials ? decodeSessionToken(credentials.token)?.orgId ?? null : null;
|
|
11260
11937
|
const orgId = typeof opts.org === "string" && opts.org !== "" ? opts.org : await ask("Organization id", claimedOrg ?? "", io, skipPrompts);
|
|
11261
11938
|
if (orgId === "") throw new Error("No organization id. Pass --org org_… or run `cow login` first.");
|
|
11262
|
-
|
|
11939
|
+
if (!orgIdSchema.safeParse(orgId).success) throw new Error(`"${orgId}" is not an organization id. They look like org_…; \`cow login\` writes the one you are signed in to.`);
|
|
11940
|
+
const webUrl = resolveWebUrl(env);
|
|
11941
|
+
const app = await chooseApp(await clientFor(merged), webUrl, typeof opts.app === "string" && opts.app !== "" ? opts.app : void 0, {
|
|
11942
|
+
requested: orgId,
|
|
11943
|
+
claimed: claimedOrg
|
|
11944
|
+
}, io, skipPrompts);
|
|
11945
|
+
const config = app.appId === "" ? {
|
|
11946
|
+
$schema: COW_CONFIG_SCHEMA_URL,
|
|
11947
|
+
orgId,
|
|
11948
|
+
appId: ""
|
|
11949
|
+
} : cowConfigSchema.parse({
|
|
11263
11950
|
$schema: COW_CONFIG_SCHEMA_URL,
|
|
11264
11951
|
orgId,
|
|
11265
|
-
|
|
11952
|
+
appId: app.appId
|
|
11266
11953
|
});
|
|
11267
11954
|
const contents = {
|
|
11268
11955
|
"cow.json": json(config),
|
|
@@ -11285,19 +11972,18 @@ function registerInit(program, env, io) {
|
|
|
11285
11972
|
}
|
|
11286
11973
|
const example = typeof opts.example === "string" ? opts.example : void 0;
|
|
11287
11974
|
const files = [...Object.keys(contents), ...example ? await copyExample(example, dir, false) : []].sort();
|
|
11288
|
-
const outcome = await createProject(resolveApiUrl(env, credentials, typeof merged.api === "string" ? merged.api : void 0), resolveCredential(env, credentials).token, name);
|
|
11289
11975
|
emit({ data: {
|
|
11290
11976
|
dir,
|
|
11291
11977
|
orgId,
|
|
11292
11978
|
files,
|
|
11293
|
-
...
|
|
11979
|
+
...app
|
|
11294
11980
|
} }, io, merged.json);
|
|
11295
11981
|
const here = relative(cwd, dir) || ".";
|
|
11296
11982
|
const scenario = files.find((file) => file.startsWith("scenarios/")) ?? "scenarios/abandoned-checkout.timeout.json";
|
|
11297
|
-
const
|
|
11983
|
+
const appLine = app.appId === "" ? `No app named yet (${app.reason}). Put its id in cow.json's "appId"; you will find it on the app's page at ${webUrl}/apps.` : `Pushes to ${app.appId}`;
|
|
11298
11984
|
io.stderr([
|
|
11299
|
-
`Created a cow
|
|
11300
|
-
|
|
11985
|
+
`Created a cow repo in ${here}`,
|
|
11986
|
+
appLine,
|
|
11301
11987
|
"",
|
|
11302
11988
|
"Next steps:",
|
|
11303
11989
|
` cd ${here}`,
|
|
@@ -11471,25 +12157,25 @@ function noGitHere(error) {
|
|
|
11471
12157
|
return failure.code === "ENOENT" || String(failure.stderr ?? "").includes("not a git repository");
|
|
11472
12158
|
}
|
|
11473
12159
|
/**
|
|
11474
|
-
* The push to restore: the one named, else this
|
|
11475
|
-
*
|
|
11476
|
-
*
|
|
12160
|
+
* The push to restore: the one named, else this app's newest. Scoped to this
|
|
12161
|
+
* directory's app, so a sibling repo's tree is never unpacked over this
|
|
12162
|
+
* one's files.
|
|
11477
12163
|
*
|
|
11478
12164
|
* The newest rather than the newest that compiled: a push stores the whole
|
|
11479
12165
|
* tree and its source archive whatever its keys then compiled to, so it is
|
|
11480
12166
|
* the last thing anyone pushed either way.
|
|
11481
12167
|
*/
|
|
11482
|
-
async function resolvePush(client, pushId,
|
|
12168
|
+
async function resolvePush(client, pushId, appId) {
|
|
11483
12169
|
if (pushId) return (await client.request(contract.pushes["pushes.get"], { params: { id: pushId } })).data;
|
|
11484
12170
|
const newest = (await client.request(contract.pushes["pushes.list"], { query: {
|
|
11485
12171
|
limit: 1,
|
|
11486
|
-
|
|
12172
|
+
appId
|
|
11487
12173
|
} })).data[0];
|
|
11488
|
-
if (!newest) throw new Error("This
|
|
12174
|
+
if (!newest) throw new Error("This app has never been pushed, so there is nothing to restore. Run `cow push` first, or name a push with --push.");
|
|
11489
12175
|
return newest;
|
|
11490
12176
|
}
|
|
11491
12177
|
async function pullPush({ client, projectDir, pushId, force }) {
|
|
11492
|
-
const push = await resolvePush(client, pushId, await
|
|
12178
|
+
const push = await resolvePush(client, pushId, await configuredApp(projectDir));
|
|
11493
12179
|
if (!force) {
|
|
11494
12180
|
const dirty = await dirtyPaths(projectDir);
|
|
11495
12181
|
if (dirty.length > 0) throw new Error(`This working tree has uncommitted changes and \`cow pull\` would write over them:\n ${dirty.join("\n ")}\nCommit or stash them, or pass --force.`);
|
|
@@ -11511,7 +12197,7 @@ async function pullPush({ client, projectDir, pushId, force }) {
|
|
|
11511
12197
|
};
|
|
11512
12198
|
}
|
|
11513
12199
|
function registerPull(program, clientFor, io) {
|
|
11514
|
-
program.command("pull").description("restore the
|
|
12200
|
+
program.command("pull").description("restore the repo's source files from a push into the current directory").option("--push <id>", "the push to restore (default: the newest one)").option("--force", "extract even when the git working tree is dirty").action(async (opts) => {
|
|
11515
12201
|
const merged = {
|
|
11516
12202
|
...program.opts(),
|
|
11517
12203
|
...opts
|
|
@@ -11532,25 +12218,6 @@ function registerPull(program, clientFor, io) {
|
|
|
11532
12218
|
|
|
11533
12219
|
//#endregion
|
|
11534
12220
|
//#region src/commands/push.ts
|
|
11535
|
-
/**
|
|
11536
|
-
* This directory's project, created when the org has none by that name.
|
|
11537
|
-
* `cow init` usually got there first; a project pushed from a machine that
|
|
11538
|
-
* only ever cloned the repo has not, and creating it here is what makes that
|
|
11539
|
-
* clone work. The name is `cow.json`'s `project`.
|
|
11540
|
-
*/
|
|
11541
|
-
async function ensureProject(client, name) {
|
|
11542
|
-
try {
|
|
11543
|
-
return (await client.request(contract.project["project.get"], { query: { name } })).data;
|
|
11544
|
-
} catch (error) {
|
|
11545
|
-
if (!(error instanceof ApiError) || error.code !== "project_missing") throw error;
|
|
11546
|
-
}
|
|
11547
|
-
try {
|
|
11548
|
-
return (await client.request(contract.project["project.create"], { body: { name } })).data;
|
|
11549
|
-
} catch (error) {
|
|
11550
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
11551
|
-
throw new Error(`This organization has no project and one could not be created (${reason}). Run \`cow init\` first.`);
|
|
11552
|
-
}
|
|
11553
|
-
}
|
|
11554
12221
|
/** Does the org already hold these bytes? A 404 is the answer, not a failure. */
|
|
11555
12222
|
async function held(client, digest) {
|
|
11556
12223
|
try {
|
|
@@ -11584,8 +12251,6 @@ function artifactFiles(projectDir, manifest) {
|
|
|
11584
12251
|
}
|
|
11585
12252
|
async function pushProject({ client, projectDir }) {
|
|
11586
12253
|
const { manifest, config } = await buildProject(projectDir);
|
|
11587
|
-
const project = await ensureProject(client, config.project);
|
|
11588
|
-
if (project.orgId !== config.orgId) throw new Error(`cow.json is for ${config.orgId} but the credential belongs to ${project.orgId}. Log in to that organization, use its pipeline key, or select a config for it with --config or COW_CONFIG (for example cow.local.json).`);
|
|
11589
12254
|
const uploaded = [];
|
|
11590
12255
|
for (const artifact of artifactFiles(projectDir, manifest)) {
|
|
11591
12256
|
if (await held(client, artifact.digest)) continue;
|
|
@@ -11602,7 +12267,8 @@ async function pushProject({ client, projectDir }) {
|
|
|
11602
12267
|
...manifest,
|
|
11603
12268
|
protocol: 3
|
|
11604
12269
|
},
|
|
11605
|
-
|
|
12270
|
+
appId: config.appId,
|
|
12271
|
+
orgId: config.orgId
|
|
11606
12272
|
} })).data,
|
|
11607
12273
|
uploaded
|
|
11608
12274
|
};
|
|
@@ -11618,10 +12284,10 @@ function pushSummary(outcome, webUrl) {
|
|
|
11618
12284
|
const stored = `Push #${push.seq} stored (${uploaded}).`;
|
|
11619
12285
|
if (push.versions.length === 0) return `${stored} Nothing changed.`;
|
|
11620
12286
|
const width = Math.max(...push.versions.map((version) => version.key.length));
|
|
11621
|
-
return [`${stored} Compiling ${push.versions.length} ${push.versions.length === 1 ? "key" : "keys"}:`, ...push.versions.map((version) => ` ${version.key.padEnd(width)} ${webUrl}/${version.kind === "journey" ? "journeys" : "templates"}/${version.key}`)].join("\n");
|
|
12287
|
+
return [`${stored} Compiling ${push.versions.length} ${push.versions.length === 1 ? "key" : "keys"}:`, ...push.versions.map((version) => ` ${version.key.padEnd(width)} ${webUrl}/${version.kind === "journey" ? "journeys" : "deliveries/templates"}/${version.key}`)].join("\n");
|
|
11622
12288
|
}
|
|
11623
12289
|
function registerPush(program, clientFor, env, io) {
|
|
11624
|
-
program.command("push").description("build
|
|
12290
|
+
program.command("push").description("build this repository and upload it; the server compiles what changed").action(async (opts) => {
|
|
11625
12291
|
const merged = {
|
|
11626
12292
|
...program.opts(),
|
|
11627
12293
|
...opts
|
|
@@ -11642,10 +12308,10 @@ function registerPush(program, clientFor, env, io) {
|
|
|
11642
12308
|
//#endregion
|
|
11643
12309
|
//#region src/commands/status.ts
|
|
11644
12310
|
/**
|
|
11645
|
-
* `cow status`: what the server holds for every key of this
|
|
11646
|
-
*
|
|
11647
|
-
*
|
|
11648
|
-
*
|
|
12311
|
+
* `cow status`: what the server holds for every key of this app, and how far
|
|
12312
|
+
* the tree in front of the developer has moved from it. Hand-written rather
|
|
12313
|
+
* than derived from `apps.status`, because the drift is the half of the
|
|
12314
|
+
* answer no server read can know: it comes from building the repository
|
|
11649
12315
|
* here and comparing the bundle digests with the versions the server has.
|
|
11650
12316
|
*/
|
|
11651
12317
|
/** Largest first: the first unit the gap fills is the one that reads best. */
|
|
@@ -11699,8 +12365,7 @@ function versionPhrase(entry, now) {
|
|
|
11699
12365
|
return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
|
|
11700
12366
|
}
|
|
11701
12367
|
function flagsPhrase(entry) {
|
|
11702
|
-
|
|
11703
|
-
return entry.enabled ? "on" : "off";
|
|
12368
|
+
return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
|
|
11704
12369
|
}
|
|
11705
12370
|
function livePhrase(entry) {
|
|
11706
12371
|
return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
|
|
@@ -11720,7 +12385,7 @@ function statusLines(status, manifest, now) {
|
|
|
11720
12385
|
const local = manifest ? localKeys(manifest) : [];
|
|
11721
12386
|
const journeys = status.keys.filter((entry) => entry.kind === "journey");
|
|
11722
12387
|
const templates = status.keys.filter((entry) => entry.kind === "template");
|
|
11723
|
-
const lines = [`${status.
|
|
12388
|
+
const lines = [`${status.appId}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
|
|
11724
12389
|
const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
|
|
11725
12390
|
const section = (title, entries) => {
|
|
11726
12391
|
if (entries.length === 0) return;
|
|
@@ -11759,19 +12424,19 @@ function statusLines(status, manifest, now) {
|
|
|
11759
12424
|
}
|
|
11760
12425
|
return lines;
|
|
11761
12426
|
}
|
|
11762
|
-
/** The
|
|
11763
|
-
async function readStatus(client,
|
|
11764
|
-
return (await client.request(contract.
|
|
12427
|
+
/** The app's status from the server, for this directory's `cow.json`. */
|
|
12428
|
+
async function readStatus(client, appId) {
|
|
12429
|
+
return (await client.request(contract.apps["apps.status"], { params: { id: appId } })).data;
|
|
11765
12430
|
}
|
|
11766
12431
|
function registerStatus(program, clientFor, io) {
|
|
11767
|
-
program.command("status").description("what this
|
|
12432
|
+
program.command("status").description("what this app's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
|
|
11768
12433
|
const merged = {
|
|
11769
12434
|
...program.opts(),
|
|
11770
12435
|
...opts
|
|
11771
12436
|
};
|
|
11772
12437
|
const config = await assertCowConfig(process.cwd());
|
|
11773
12438
|
const built = await buildProject(process.cwd()).catch(() => null);
|
|
11774
|
-
const status = await readStatus(await clientFor(merged), config.
|
|
12439
|
+
const status = await readStatus(await clientFor(merged), config.appId);
|
|
11775
12440
|
if (merged.json === true) {
|
|
11776
12441
|
emit({ data: status }, io, true);
|
|
11777
12442
|
return;
|
|
@@ -12040,7 +12705,7 @@ function emit(result, io, json) {
|
|
|
12040
12705
|
}
|
|
12041
12706
|
function buildProgram(env, io) {
|
|
12042
12707
|
const program = new Command();
|
|
12043
|
-
program.name("cow").description("Admin CLI for Cowliss: a thin client of the dashboard API").option("--api <url>", "API base URL (overrides env and saved config)").option("--json", "force compact single-line JSON output").option("--config <file>", `
|
|
12708
|
+
program.name("cow").description("Admin CLI for Cowliss: a thin client of the dashboard API").option("--api <url>", "API base URL (overrides env and saved config)").option("--json", "force compact single-line JSON output").option("--config <file>", `config file to read instead of ${DEFAULT_CONFIG_FILE} (overrides COW_CONFIG)`);
|
|
12044
12709
|
program.hook("preAction", () => {
|
|
12045
12710
|
const flag = program.opts().config;
|
|
12046
12711
|
setConfigFile((typeof flag === "string" ? flag : void 0) ?? env.COW_CONFIG ?? "cow.json");
|
|
@@ -12062,7 +12727,7 @@ function buildProgram(env, io) {
|
|
|
12062
12727
|
};
|
|
12063
12728
|
registerContractCommands(program, run);
|
|
12064
12729
|
registerAuth(program, env, io);
|
|
12065
|
-
registerInit(program, env, io);
|
|
12730
|
+
registerInit(program, clientFor, env, io);
|
|
12066
12731
|
registerAdd(program, io);
|
|
12067
12732
|
registerBuild(program, io);
|
|
12068
12733
|
registerPush(program, clientFor, env, io);
|