@koda-sl/baker-cli 0.277.0 → 0.278.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/dist/cli.js +1287 -145
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -12875,7 +12875,22 @@ var ANALYTICS_EVENT_TYPES = [
|
|
|
12875
12875
|
* know who this was" unanswerable.
|
|
12876
12876
|
*/
|
|
12877
12877
|
"identify",
|
|
12878
|
-
/**
|
|
12878
|
+
/**
|
|
12879
|
+
* A named event: an escape hatch a page defines, or one a client's own server
|
|
12880
|
+
* posted to `POST /v1/events`.
|
|
12881
|
+
*
|
|
12882
|
+
* One type for both, deliberately. There was briefly a `server_event`, and
|
|
12883
|
+
* the type was the mistake: every report that shows named events whitelists
|
|
12884
|
+
* `custom`, so a server-sent "Deal won" was structurally invisible on the
|
|
12885
|
+
* Page events card, in Top pages and in the events catalogue — the three
|
|
12886
|
+
* screens somebody would look at after being told it had arrived. GA4 makes
|
|
12887
|
+
* the same call for the same reason: a Measurement Protocol event is just an
|
|
12888
|
+
* event, and the transport is a property.
|
|
12889
|
+
*
|
|
12890
|
+
* Which collector wrote it is {@link ANALYTICS_SOURCES} — `client` for a
|
|
12891
|
+
* page, `ingest` for a third party's server — and every report that needs to
|
|
12892
|
+
* tell them apart filters on that.
|
|
12893
|
+
*/
|
|
12879
12894
|
"custom"
|
|
12880
12895
|
];
|
|
12881
12896
|
var ANALYTICS_DEVICE_TYPES = ["desktop", "mobile", "tablet", "bot", "unknown"];
|
|
@@ -13338,7 +13353,16 @@ var analyticsPresetSchema = z27.enum([
|
|
|
13338
13353
|
* marketer could see that nothing was counted and had no way to find out what
|
|
13339
13354
|
* was available to count.
|
|
13340
13355
|
*/
|
|
13341
|
-
"conversions"
|
|
13356
|
+
"conversions",
|
|
13357
|
+
/**
|
|
13358
|
+
* Every event as it was stored, newest first — the one preset that does not
|
|
13359
|
+
* reduce.
|
|
13360
|
+
*
|
|
13361
|
+
* Deliberately not offered as a `baker analytics` preset. It is the raw feed
|
|
13362
|
+
* a person watches while wiring something up, and an agent asked to describe
|
|
13363
|
+
* an account wants the counts rather than the tape.
|
|
13364
|
+
*/
|
|
13365
|
+
"stream"
|
|
13342
13366
|
]);
|
|
13343
13367
|
var ANALYTICS_PRESETS = analyticsPresetSchema.options;
|
|
13344
13368
|
var analyticsQueryRequestSchema = z27.object({
|
|
@@ -13398,6 +13422,29 @@ var analyticsQueryRequestSchema = z27.object({
|
|
|
13398
13422
|
* superset and the right default for "how is paid doing".
|
|
13399
13423
|
*/
|
|
13400
13424
|
adPlatform: z27.enum(AD_PLATFORMS).optional(),
|
|
13425
|
+
/**
|
|
13426
|
+
* Read only what one collector produced: `site` for everything Baker
|
|
13427
|
+
* measured itself, `systems` for what a client's own server posted to
|
|
13428
|
+
* `POST /v1/events`.
|
|
13429
|
+
*
|
|
13430
|
+
* Omit it for all of it, which is the default and the honest superset.
|
|
13431
|
+
* Only the reads where the split is a question somebody asks honour it —
|
|
13432
|
+
* the totals, the trend, the two page-event reads, the conversion
|
|
13433
|
+
* breakdown and the events catalogue. A session-scoped report cannot: an
|
|
13434
|
+
* ingest row carries no `session_id`, deliberately, so there is no visit
|
|
13435
|
+
* for it to be counted in.
|
|
13436
|
+
*/
|
|
13437
|
+
eventOrigin: z27.enum(["site", "systems"]).optional(),
|
|
13438
|
+
/**
|
|
13439
|
+
* `stream` only: keep just one kind of event, named by the key
|
|
13440
|
+
* `conversions` hands back in its catalogue.
|
|
13441
|
+
*
|
|
13442
|
+
* The key rather than `eventName`, because three of the four families have
|
|
13443
|
+
* no name of their own — a Form's trigger, a submit, an outbound click —
|
|
13444
|
+
* and a filter that could only name the fourth would be a filter that hides
|
|
13445
|
+
* most of the feed from itself.
|
|
13446
|
+
*/
|
|
13447
|
+
eventKey: z27.string().optional(),
|
|
13401
13448
|
/** Return the long breakdowns and the per-day trend. */
|
|
13402
13449
|
full: z27.boolean().optional(),
|
|
13403
13450
|
/**
|
|
@@ -13670,6 +13717,7 @@ var analyticsTagRowSchema = z27.object({
|
|
|
13670
13717
|
/** Converting visits per entrance. */
|
|
13671
13718
|
conversionRate: z27.number().min(0).max(1).nullable()
|
|
13672
13719
|
});
|
|
13720
|
+
var ANALYTICS_EVENT_ORIGINS = ["site", "systems", "both"];
|
|
13673
13721
|
var analyticsCustomEventRowSchema = z27.object({
|
|
13674
13722
|
eventName: z27.string(),
|
|
13675
13723
|
events: z27.number().int().nonnegative(),
|
|
@@ -13684,14 +13732,28 @@ var analyticsCustomEventRowSchema = z27.object({
|
|
|
13684
13732
|
* reader already knowing what the page chose to record.
|
|
13685
13733
|
*/
|
|
13686
13734
|
propertyKeys: z27.array(z27.string()),
|
|
13687
|
-
|
|
13688
|
-
|
|
13735
|
+
/**
|
|
13736
|
+
* What these events were worth, per currency.
|
|
13737
|
+
*
|
|
13738
|
+
* A map and never one number, for the reason the person card's `money` is
|
|
13739
|
+
* one: a name carrying 99,882.50 EUR and 1,000 USD is not worth 100,882.50
|
|
13740
|
+
* of anything, and there is no rate in this product to make it so.
|
|
13741
|
+
*
|
|
13742
|
+
* The `''` key is a value a *page* reported — `value` with no currency,
|
|
13743
|
+
* which is a score on the page's own terms rather than money. Kept rather
|
|
13744
|
+
* than dropped, because for those events it is the only number there is.
|
|
13745
|
+
*/
|
|
13746
|
+
money: z27.record(z27.string(), z27.number()).default({}),
|
|
13747
|
+
landings: z27.array(z27.string()),
|
|
13748
|
+
/** Which collector produced this name — and `both` when more than one did. */
|
|
13749
|
+
origin: z27.enum(ANALYTICS_EVENT_ORIGINS)
|
|
13689
13750
|
});
|
|
13690
13751
|
var analyticsEventBreakdownRowSchema = z27.object({
|
|
13691
13752
|
value: z27.string(),
|
|
13692
13753
|
events: z27.number().int().nonnegative(),
|
|
13693
13754
|
sessions: z27.number().int().nonnegative(),
|
|
13694
|
-
|
|
13755
|
+
/** Per currency, exactly as {@link analyticsCustomEventRowSchema.money}. */
|
|
13756
|
+
money: z27.record(z27.string(), z27.number()).default({})
|
|
13695
13757
|
});
|
|
13696
13758
|
var analyticsCustomFunnelStepSchema = z27.object({
|
|
13697
13759
|
stepIndex: z27.number().int().positive(),
|
|
@@ -14020,6 +14082,16 @@ var analyticsConversionRowSchema = z27.object({
|
|
|
14020
14082
|
var analyticsEventCatalogRowSchema = z27.object({
|
|
14021
14083
|
/** `form:…` | `submit:…` | `page:…` | `exit:…` — parse with `parseEventKey`. */
|
|
14022
14084
|
eventKey: z27.string(),
|
|
14085
|
+
/**
|
|
14086
|
+
* Which collector produced this key — and `both` when more than one did.
|
|
14087
|
+
*
|
|
14088
|
+
* On the row rather than in the key, which is the whole shape of the change
|
|
14089
|
+
* that removed the `server:` family: a picker can say where an event came
|
|
14090
|
+
* from without a filter round-trip, and a report can still separate the two
|
|
14091
|
+
* populations at any grain, but nothing downstream has to learn a second
|
|
14092
|
+
* vocabulary to count one.
|
|
14093
|
+
*/
|
|
14094
|
+
origin: z27.enum(ANALYTICS_EVENT_ORIGINS),
|
|
14023
14095
|
events: z27.number(),
|
|
14024
14096
|
sessions: z27.number(),
|
|
14025
14097
|
/** The Form node's kind, for a `form:` key. Empty otherwise. */
|
|
@@ -14108,7 +14180,7 @@ var analyticsAdPlatformTrafficSchema = z27.object({
|
|
|
14108
14180
|
});
|
|
14109
14181
|
var analyticsPageInfoSchema = z27.object({
|
|
14110
14182
|
/** Which field of the response was paged. */
|
|
14111
|
-
block: z27.enum(["visitors", "submissions", "deliveries"]),
|
|
14183
|
+
block: z27.enum(["visitors", "submissions", "deliveries", "stream"]),
|
|
14112
14184
|
page: z27.number().int(),
|
|
14113
14185
|
pageSize: z27.number().int(),
|
|
14114
14186
|
returned: z27.number().int(),
|
|
@@ -14116,6 +14188,17 @@ var analyticsPageInfoSchema = z27.object({
|
|
|
14116
14188
|
});
|
|
14117
14189
|
var analyticsTimelineRowSchema = z27.object({
|
|
14118
14190
|
timestamp: z27.string(),
|
|
14191
|
+
/**
|
|
14192
|
+
* The visit this event belongs to, and the browser that made it.
|
|
14193
|
+
*
|
|
14194
|
+
* Empty on anything that belongs to neither — a settlement, and an event a
|
|
14195
|
+
* client's own server posted about somebody who was never on a page. Carried
|
|
14196
|
+
* because the raw feed (`stream`) is a company-wide list rather than one
|
|
14197
|
+
* visit's, so a row has to say who it was about before a reader can get from
|
|
14198
|
+
* it to the person. On a replay both are constant and simply confirm it.
|
|
14199
|
+
*/
|
|
14200
|
+
sessionId: z27.string(),
|
|
14201
|
+
anonId: z27.string(),
|
|
14119
14202
|
/**
|
|
14120
14203
|
* Where the producer put this event in its own order, from 1. `0` means it
|
|
14121
14204
|
* did not number this one.
|
|
@@ -14152,8 +14235,45 @@ var analyticsTimelineRowSchema = z27.object({
|
|
|
14152
14235
|
targetUrl: z27.string(),
|
|
14153
14236
|
/** A number the page reported alongside the event. */
|
|
14154
14237
|
value: z27.number(),
|
|
14238
|
+
/**
|
|
14239
|
+
* The currency that number is in, when it has one.
|
|
14240
|
+
*
|
|
14241
|
+
* Only a server-sent event carries it — a page reports a `value` on its own
|
|
14242
|
+
* terms, a CRM reports a deal in euros — and without it the replay prints a
|
|
14243
|
+
* bare `4200.5` beside a closed deal, which is a number nobody can act on.
|
|
14244
|
+
*/
|
|
14245
|
+
valueCurrency: z27.string().default(""),
|
|
14155
14246
|
/** The visitor's digest, when they had identified themselves by this point. */
|
|
14156
14247
|
identityHash: z27.string(),
|
|
14248
|
+
/**
|
|
14249
|
+
* The browser to attribute this row to, once the identity has been resolved.
|
|
14250
|
+
*
|
|
14251
|
+
* `anonId` when the row has one, and otherwise the browser {@link identityHash}
|
|
14252
|
+
* resolves to through `identities`. Empty when neither exists.
|
|
14253
|
+
*
|
|
14254
|
+
* It is a separate field rather than a coalesce into `anonId` because the two
|
|
14255
|
+
* claims are not the same: `anonId` is the cookie the row was written with,
|
|
14256
|
+
* and a server-sent event was written with none and must not start claiming
|
|
14257
|
+
* one. This is the *read's* answer to "who was that", and it is retroactive
|
|
14258
|
+
* and cross-device by construction — a "Deal won" posted in March attaches to
|
|
14259
|
+
* the person the moment they type that address into a Form in April, and to
|
|
14260
|
+
* every browser they have used here.
|
|
14261
|
+
*
|
|
14262
|
+
* Only `event_stream` fills it. Empty everywhere else, which is honest: a
|
|
14263
|
+
* session replay deliberately does not resolve, because an event that
|
|
14264
|
+
* happened during no visit must not be placed inside one.
|
|
14265
|
+
*/
|
|
14266
|
+
personAnonId: z27.string().default(""),
|
|
14267
|
+
/**
|
|
14268
|
+
* What this row knows the person by, when it does not know their browser:
|
|
14269
|
+
* `email` | `phone` | `external` | `click` | `cookie`, or empty.
|
|
14270
|
+
*
|
|
14271
|
+
* The feed's Who column reads it. A row naming a person by a means that has
|
|
14272
|
+
* no face here yet — a digest nobody has typed into a Form, an ad click, one
|
|
14273
|
+
* of Meta's cookies — is not a row about nobody, and rendering it as one is
|
|
14274
|
+
* what made the server-sent half of the feed look unattached to the rest.
|
|
14275
|
+
*/
|
|
14276
|
+
knownBy: z27.string().default(""),
|
|
14157
14277
|
/**
|
|
14158
14278
|
* `email` | `phone` — which of the two they gave.
|
|
14159
14279
|
*
|
|
@@ -14162,6 +14282,22 @@ var analyticsTimelineRowSchema = z27.object({
|
|
|
14162
14282
|
* a replay renders the same sentence twice and reads as a duplicated event.
|
|
14163
14283
|
*/
|
|
14164
14284
|
identityKind: z27.string(),
|
|
14285
|
+
/**
|
|
14286
|
+
* The `user_data` block this event carried, exactly as it arrived.
|
|
14287
|
+
*
|
|
14288
|
+
* The contact digests (`email_sha256`, `phone_sha256`, `external_id_sha256`),
|
|
14289
|
+
* the click ids, Meta's cookies, and the advanced-matching /
|
|
14290
|
+
* enhanced-conversions fields — first name, city, postcode, country. Empty on
|
|
14291
|
+
* everything a page produced, which is most rows.
|
|
14292
|
+
*
|
|
14293
|
+
* **Per event, and deliberately not merged** — unlike the person card, which
|
|
14294
|
+
* merges the same fields newest-wins across everything anyone ever sent. The
|
|
14295
|
+
* question one event answers is "what did *this request* contain", and the
|
|
14296
|
+
* answer has to be allowed to be *worse* than what is known about the person
|
|
14297
|
+
* overall. That is how a sender finds the field they forgot to map: the
|
|
14298
|
+
* person has a postcode, and this event does not.
|
|
14299
|
+
*/
|
|
14300
|
+
userData: z27.record(z27.string(), z27.string()).default({}),
|
|
14165
14301
|
sideEffectType: z27.string(),
|
|
14166
14302
|
sideEffectName: z27.string(),
|
|
14167
14303
|
sideEffectId: z27.string(),
|
|
@@ -14293,6 +14429,63 @@ var analyticsVisitProfileSchema = z27.object({
|
|
|
14293
14429
|
conversions: z27.number().int().nonnegative(),
|
|
14294
14430
|
engagementMs: z27.number().int().nonnegative()
|
|
14295
14431
|
});
|
|
14432
|
+
var analyticsPersonKeySchema = z27.object({
|
|
14433
|
+
/** `email` | `phone` | `external` | `browser` | a click-id name | `fbp` | `fbc`. */
|
|
14434
|
+
kind: z27.string(),
|
|
14435
|
+
/** A digest for a contact detail, the value itself for a cookie or a click id. */
|
|
14436
|
+
value: z27.string(),
|
|
14437
|
+
firstSeen: z27.string(),
|
|
14438
|
+
lastSeen: z27.string(),
|
|
14439
|
+
/** How many times it was observed beside another key — every submission repeats it. */
|
|
14440
|
+
observations: z27.number().int().nonnegative(),
|
|
14441
|
+
sessions: z27.number().int().nonnegative(),
|
|
14442
|
+
/** `client` | `edge` | `server` | `ingest` — which collector first saw it. */
|
|
14443
|
+
source: z27.string(),
|
|
14444
|
+
/** The Form it was first seen on, where it came from one. */
|
|
14445
|
+
flowSlug: z27.string(),
|
|
14446
|
+
landingId: z27.string()
|
|
14447
|
+
});
|
|
14448
|
+
var analyticsPersonProfileSchema = z27.object({
|
|
14449
|
+
firstSeen: z27.string(),
|
|
14450
|
+
lastSeen: z27.string(),
|
|
14451
|
+
sessions: z27.number().int().nonnegative(),
|
|
14452
|
+
/** How many browsers resolved to this person. More than one is cross-device. */
|
|
14453
|
+
devices: z27.number().int().nonnegative(),
|
|
14454
|
+
pageViews: z27.number().int().nonnegative(),
|
|
14455
|
+
conversions: z27.number().int().nonnegative(),
|
|
14456
|
+
/**
|
|
14457
|
+
* What they are worth, per currency — `{ EUR: 4753 }`.
|
|
14458
|
+
*
|
|
14459
|
+
* A map rather than an amount and a code, because the two-column shape had
|
|
14460
|
+
* no honest answer for somebody with a deal in each of two currencies: it
|
|
14461
|
+
* summed them and labelled the total with whichever code came back first.
|
|
14462
|
+
* Only a server event ever carries money, and a retried post is counted
|
|
14463
|
+
* once, on `event_uid`.
|
|
14464
|
+
*/
|
|
14465
|
+
money: z27.record(z27.string(), z27.number()),
|
|
14466
|
+
engagementMs: z27.number().int().nonnegative(),
|
|
14467
|
+
/** How many of their events a client's own server posted. */
|
|
14468
|
+
serverEvents: z27.number().int().nonnegative(),
|
|
14469
|
+
country: z27.string(),
|
|
14470
|
+
region: z27.string(),
|
|
14471
|
+
city: z27.string(),
|
|
14472
|
+
deviceType: z27.string(),
|
|
14473
|
+
browser: z27.string(),
|
|
14474
|
+
os: z27.string(),
|
|
14475
|
+
utmSource: z27.string(),
|
|
14476
|
+
utmMedium: z27.string(),
|
|
14477
|
+
utmCampaign: z27.string(),
|
|
14478
|
+
referrerSource: z27.string(),
|
|
14479
|
+
entryLandingId: z27.string(),
|
|
14480
|
+
entryPath: z27.string(),
|
|
14481
|
+
/**
|
|
14482
|
+
* The customer-matching block, merged newest-wins across everything anyone
|
|
14483
|
+
* has ever told us about them — `city`, `postal_code`, `country`,
|
|
14484
|
+
* `first_name_sha256`, `fbp`. Keyed exactly as the store holds it, so a key
|
|
14485
|
+
* ending `_sha256` says its value is a digest and cannot be shown.
|
|
14486
|
+
*/
|
|
14487
|
+
matchKeys: z27.record(z27.string(), z27.string())
|
|
14488
|
+
});
|
|
14296
14489
|
var analyticsVisitorRowSchema = z27.object({
|
|
14297
14490
|
anonId: z27.string(),
|
|
14298
14491
|
firstSeen: z27.string(),
|
|
@@ -14315,6 +14508,18 @@ var analyticsVisitorRowSchema = z27.object({
|
|
|
14315
14508
|
utmCampaign: z27.string(),
|
|
14316
14509
|
clickPlatform: z27.string(),
|
|
14317
14510
|
campaignParams: z27.record(z27.string(), z27.string()),
|
|
14511
|
+
/**
|
|
14512
|
+
* The click ids this person arrived on, by their own names — `gclid`,
|
|
14513
|
+
* `fbclid`, `msclkid`, `li_fat_id`, `ttclid`, `twclid`, `wbraid`, `gbraid`.
|
|
14514
|
+
*
|
|
14515
|
+
* Only the ones that were present. The endpoint has always returned all eight
|
|
14516
|
+
* columns and nothing above it carried them, so the one screen whose subject
|
|
14517
|
+
* is a person could name the platform that paid for the click and not the
|
|
14518
|
+
* click — which is the value a platform's own reporting is matched on, and
|
|
14519
|
+
* therefore the value somebody is looking for when a conversion did not show
|
|
14520
|
+
* up on the other side.
|
|
14521
|
+
*/
|
|
14522
|
+
clickIds: z27.record(z27.string(), z27.string()),
|
|
14318
14523
|
country: z27.string(),
|
|
14319
14524
|
region: z27.string(),
|
|
14320
14525
|
city: z27.string(),
|
|
@@ -14376,7 +14581,16 @@ var analyticsQueryDataSchema = z27.object({
|
|
|
14376
14581
|
/** Every event these pages produced, counted or not. The "add a conversion" picker. */
|
|
14377
14582
|
eventCatalog: z27.array(analyticsEventCatalogRowSchema).optional(),
|
|
14378
14583
|
timeline: z27.array(analyticsTimelineRowSchema).optional(),
|
|
14584
|
+
/**
|
|
14585
|
+
* The raw feed. Same row shape as a replay, because it is the same rows —
|
|
14586
|
+
* what changes is that this one is bounded by a window rather than by a visit.
|
|
14587
|
+
*/
|
|
14588
|
+
stream: z27.array(analyticsTimelineRowSchema).optional(),
|
|
14379
14589
|
visitorSessions: z27.array(analyticsVisitorSessionSchema).optional(),
|
|
14590
|
+
/** Every key this person is known by — contact details, browsers, clicks, cookies. */
|
|
14591
|
+
personKeys: z27.array(analyticsPersonKeySchema).optional(),
|
|
14592
|
+
/** The same person, rolled up: their totals across every device, and what is known about them. */
|
|
14593
|
+
personProfile: analyticsPersonProfileSchema.optional(),
|
|
14380
14594
|
visitProfile: analyticsVisitProfileSchema.optional(),
|
|
14381
14595
|
visitors: z27.array(analyticsVisitorRowSchema).optional(),
|
|
14382
14596
|
/**
|
|
@@ -16911,7 +17125,7 @@ function parseCsvLine(line) {
|
|
|
16911
17125
|
}
|
|
16912
17126
|
}
|
|
16913
17127
|
cells.push(current);
|
|
16914
|
-
return cells.map((
|
|
17128
|
+
return cells.map((cell3) => cell3.trim());
|
|
16915
17129
|
}
|
|
16916
17130
|
function parseListFileArg(path38, maxRows) {
|
|
16917
17131
|
if (typeof path38 !== "string" || path38.length === 0) {
|
|
@@ -26881,6 +27095,808 @@ Full guides: __tooling__/docs/tools/baker/ads-<platform>.md (google|meta|linkedi
|
|
|
26881
27095
|
}
|
|
26882
27096
|
});
|
|
26883
27097
|
|
|
27098
|
+
// ../api/src/event-ingest/arrivals.ts
|
|
27099
|
+
import { z as z30 } from "zod";
|
|
27100
|
+
|
|
27101
|
+
// ../api/src/event-ingest/mode.ts
|
|
27102
|
+
var INGEST_MODE_HEADER = "X-Baker-Mode";
|
|
27103
|
+
var INGEST_MODES = ["live", "test"];
|
|
27104
|
+
|
|
27105
|
+
// ../api/src/event-ingest/arrivals.ts
|
|
27106
|
+
var INGEST_ARRIVALS_MAX = 200;
|
|
27107
|
+
var INGEST_ARRIVALS_DEFAULT = 25;
|
|
27108
|
+
var ingestArrivalsRequestSchema = z30.object({
|
|
27109
|
+
/** `test` shows only rehearsals, `live` only real traffic. Absent shows both. */
|
|
27110
|
+
mode: z30.enum(INGEST_MODES).optional(),
|
|
27111
|
+
/** `rejected` is the one worth asking for on its own — it is the whole reason to look. */
|
|
27112
|
+
outcome: z30.enum(["accepted", "rejected"]).optional(),
|
|
27113
|
+
/** Narrow to one event name, for a company posting several kinds. */
|
|
27114
|
+
eventName: z30.string().max(120).optional(),
|
|
27115
|
+
limit: z30.coerce.number().int().positive().max(INGEST_ARRIVALS_MAX).optional().default(INGEST_ARRIVALS_DEFAULT),
|
|
27116
|
+
/** Include the masked raw body and the per-field previews. Off by default — it is most of the bytes. */
|
|
27117
|
+
full: z30.boolean().optional()
|
|
27118
|
+
});
|
|
27119
|
+
|
|
27120
|
+
// ../api/src/event-ingest/endpoint.ts
|
|
27121
|
+
var EVENT_INGEST_PATH = "/v1/events";
|
|
27122
|
+
function eventIngestUrl(brandedUrl, convexSiteUrl) {
|
|
27123
|
+
if (brandedUrl) return `${brandedUrl.replace(/\/$/, "")}${EVENT_INGEST_PATH}`;
|
|
27124
|
+
return `${convexSiteUrl.replace(/\/$/, "")}${EVENT_INGEST_PATH}`;
|
|
27125
|
+
}
|
|
27126
|
+
var MODE_HINT = `
|
|
27127
|
+
# Rehearse the same request: add -H '${INGEST_MODE_HEADER}: test'
|
|
27128
|
+
# It is validated and answered exactly the same way, and nothing is written.`;
|
|
27129
|
+
function eventIngestCurl(url, apiKey) {
|
|
27130
|
+
const body = JSON.stringify({
|
|
27131
|
+
events: [
|
|
27132
|
+
{
|
|
27133
|
+
event_name: "deal_won",
|
|
27134
|
+
user_data: { email: "person@example.com" },
|
|
27135
|
+
value: "1200.00",
|
|
27136
|
+
currency: "EUR"
|
|
27137
|
+
}
|
|
27138
|
+
]
|
|
27139
|
+
});
|
|
27140
|
+
return [
|
|
27141
|
+
`curl -X POST ${url} \\`,
|
|
27142
|
+
` -H 'Authorization: Bearer ${apiKey}' \\`,
|
|
27143
|
+
` -H 'Content-Type: application/json' \\`,
|
|
27144
|
+
` -d '${body}'`
|
|
27145
|
+
].join("\n") + MODE_HINT;
|
|
27146
|
+
}
|
|
27147
|
+
|
|
27148
|
+
// ../api/src/event-ingest/currency.ts
|
|
27149
|
+
var MINOR_UNITS = {
|
|
27150
|
+
AED: 2,
|
|
27151
|
+
AFN: 2,
|
|
27152
|
+
ALL: 2,
|
|
27153
|
+
AMD: 2,
|
|
27154
|
+
ANG: 2,
|
|
27155
|
+
AOA: 2,
|
|
27156
|
+
ARS: 2,
|
|
27157
|
+
AUD: 2,
|
|
27158
|
+
AWG: 2,
|
|
27159
|
+
AZN: 2,
|
|
27160
|
+
BAM: 2,
|
|
27161
|
+
BBD: 2,
|
|
27162
|
+
BDT: 2,
|
|
27163
|
+
BGN: 2,
|
|
27164
|
+
BHD: 3,
|
|
27165
|
+
BIF: 0,
|
|
27166
|
+
BMD: 2,
|
|
27167
|
+
BND: 2,
|
|
27168
|
+
BOB: 2,
|
|
27169
|
+
BOV: 2,
|
|
27170
|
+
BRL: 2,
|
|
27171
|
+
BSD: 2,
|
|
27172
|
+
BTN: 2,
|
|
27173
|
+
BWP: 2,
|
|
27174
|
+
BYN: 2,
|
|
27175
|
+
BZD: 2,
|
|
27176
|
+
CAD: 2,
|
|
27177
|
+
CDF: 2,
|
|
27178
|
+
CHE: 2,
|
|
27179
|
+
CHF: 2,
|
|
27180
|
+
CHW: 2,
|
|
27181
|
+
CLF: 4,
|
|
27182
|
+
CLP: 0,
|
|
27183
|
+
CNY: 2,
|
|
27184
|
+
COP: 2,
|
|
27185
|
+
COU: 4,
|
|
27186
|
+
CRC: 2,
|
|
27187
|
+
CUP: 2,
|
|
27188
|
+
CVE: 2,
|
|
27189
|
+
CZK: 2,
|
|
27190
|
+
DJF: 0,
|
|
27191
|
+
DKK: 2,
|
|
27192
|
+
DOP: 2,
|
|
27193
|
+
DZD: 2,
|
|
27194
|
+
EGP: 2,
|
|
27195
|
+
ERN: 2,
|
|
27196
|
+
ETB: 2,
|
|
27197
|
+
EUR: 2,
|
|
27198
|
+
FJD: 2,
|
|
27199
|
+
FKP: 2,
|
|
27200
|
+
GBP: 2,
|
|
27201
|
+
GEL: 2,
|
|
27202
|
+
GHS: 2,
|
|
27203
|
+
GIP: 2,
|
|
27204
|
+
GMD: 2,
|
|
27205
|
+
GNF: 0,
|
|
27206
|
+
GTQ: 2,
|
|
27207
|
+
GYD: 2,
|
|
27208
|
+
HKD: 2,
|
|
27209
|
+
HNL: 2,
|
|
27210
|
+
HTG: 2,
|
|
27211
|
+
HUF: 2,
|
|
27212
|
+
IDR: 2,
|
|
27213
|
+
ILS: 2,
|
|
27214
|
+
INR: 2,
|
|
27215
|
+
IQD: 3,
|
|
27216
|
+
IRR: 2,
|
|
27217
|
+
ISK: 0,
|
|
27218
|
+
JMD: 2,
|
|
27219
|
+
JOD: 3,
|
|
27220
|
+
JPY: 0,
|
|
27221
|
+
KES: 2,
|
|
27222
|
+
KGS: 2,
|
|
27223
|
+
KHR: 2,
|
|
27224
|
+
KMF: 0,
|
|
27225
|
+
KPW: 2,
|
|
27226
|
+
KRW: 0,
|
|
27227
|
+
KWD: 3,
|
|
27228
|
+
KYD: 2,
|
|
27229
|
+
KZT: 2,
|
|
27230
|
+
LAK: 2,
|
|
27231
|
+
LBP: 2,
|
|
27232
|
+
LKR: 2,
|
|
27233
|
+
LRD: 2,
|
|
27234
|
+
LSL: 2,
|
|
27235
|
+
LYD: 3,
|
|
27236
|
+
MAD: 2,
|
|
27237
|
+
MDL: 2,
|
|
27238
|
+
MGA: 2,
|
|
27239
|
+
MKD: 2,
|
|
27240
|
+
MMK: 2,
|
|
27241
|
+
MNT: 2,
|
|
27242
|
+
MOP: 2,
|
|
27243
|
+
MRU: 2,
|
|
27244
|
+
MUR: 2,
|
|
27245
|
+
MVR: 2,
|
|
27246
|
+
MWK: 2,
|
|
27247
|
+
MXN: 2,
|
|
27248
|
+
MXV: 2,
|
|
27249
|
+
MYR: 2,
|
|
27250
|
+
MZN: 2,
|
|
27251
|
+
NAD: 2,
|
|
27252
|
+
NGN: 2,
|
|
27253
|
+
NIO: 2,
|
|
27254
|
+
NOK: 2,
|
|
27255
|
+
NPR: 2,
|
|
27256
|
+
NZD: 2,
|
|
27257
|
+
OMR: 3,
|
|
27258
|
+
PAB: 2,
|
|
27259
|
+
PEN: 2,
|
|
27260
|
+
PGK: 2,
|
|
27261
|
+
PHP: 2,
|
|
27262
|
+
PKR: 2,
|
|
27263
|
+
PLN: 2,
|
|
27264
|
+
PYG: 0,
|
|
27265
|
+
QAR: 2,
|
|
27266
|
+
RON: 2,
|
|
27267
|
+
RSD: 2,
|
|
27268
|
+
RUB: 2,
|
|
27269
|
+
RWF: 0,
|
|
27270
|
+
SAR: 2,
|
|
27271
|
+
SBD: 2,
|
|
27272
|
+
SCR: 2,
|
|
27273
|
+
SDG: 2,
|
|
27274
|
+
SEK: 2,
|
|
27275
|
+
SGD: 2,
|
|
27276
|
+
SHP: 2,
|
|
27277
|
+
SLE: 2,
|
|
27278
|
+
SOS: 2,
|
|
27279
|
+
SRD: 2,
|
|
27280
|
+
SSP: 2,
|
|
27281
|
+
STN: 2,
|
|
27282
|
+
SVC: 2,
|
|
27283
|
+
SYP: 2,
|
|
27284
|
+
SZL: 2,
|
|
27285
|
+
THB: 2,
|
|
27286
|
+
TJS: 2,
|
|
27287
|
+
TMT: 2,
|
|
27288
|
+
TND: 3,
|
|
27289
|
+
TOP: 2,
|
|
27290
|
+
TRY: 2,
|
|
27291
|
+
TTD: 2,
|
|
27292
|
+
TWD: 2,
|
|
27293
|
+
TZS: 2,
|
|
27294
|
+
UAH: 2,
|
|
27295
|
+
UGX: 0,
|
|
27296
|
+
USD: 2,
|
|
27297
|
+
USN: 2,
|
|
27298
|
+
UYI: 0,
|
|
27299
|
+
UYU: 2,
|
|
27300
|
+
UYW: 4,
|
|
27301
|
+
UZS: 2,
|
|
27302
|
+
VED: 2,
|
|
27303
|
+
VES: 2,
|
|
27304
|
+
VND: 0,
|
|
27305
|
+
VUV: 0,
|
|
27306
|
+
WST: 2,
|
|
27307
|
+
XAF: 0,
|
|
27308
|
+
XAG: 0,
|
|
27309
|
+
XAU: 0,
|
|
27310
|
+
XBA: 0,
|
|
27311
|
+
XBB: 0,
|
|
27312
|
+
XBC: 0,
|
|
27313
|
+
XBD: 0,
|
|
27314
|
+
XCD: 2,
|
|
27315
|
+
XCG: 2,
|
|
27316
|
+
XDR: 0,
|
|
27317
|
+
XOF: 0,
|
|
27318
|
+
XPD: 0,
|
|
27319
|
+
XPF: 0,
|
|
27320
|
+
XPT: 0,
|
|
27321
|
+
XSU: 0,
|
|
27322
|
+
XTS: 0,
|
|
27323
|
+
XUA: 0,
|
|
27324
|
+
XXX: 0,
|
|
27325
|
+
YER: 2,
|
|
27326
|
+
ZAR: 2,
|
|
27327
|
+
ZMW: 2,
|
|
27328
|
+
ZWG: 2,
|
|
27329
|
+
// Withdrawn recently enough that a client's CRM still carries them. Accepting
|
|
27330
|
+
// a code that was legal tender last year costs nothing; rejecting a real
|
|
27331
|
+
// historical `won` costs the client a reconciliation they cannot fix.
|
|
27332
|
+
SLL: 2,
|
|
27333
|
+
ZWL: 2,
|
|
27334
|
+
CUC: 2,
|
|
27335
|
+
HRK: 2
|
|
27336
|
+
};
|
|
27337
|
+
var ISO_4217_CODES = new Set(Object.keys(MINOR_UNITS));
|
|
27338
|
+
|
|
27339
|
+
// ../api/src/event-ingest/names.ts
|
|
27340
|
+
var STANDARD_EVENT_NAMES = [
|
|
27341
|
+
{ name: "page_view", means: "A page was served somewhere Baker does not run." },
|
|
27342
|
+
{ name: "view_content", means: "Someone looked at a specific thing \u2014 a listing, a property, an article." },
|
|
27343
|
+
{ name: "lead", means: "Someone became a lead: they handed over a way to reach them." },
|
|
27344
|
+
{ name: "contact", means: "Someone got in touch \u2014 a call, a chat, an email in." },
|
|
27345
|
+
{ name: "schedule", means: "A meeting, viewing or call was booked." },
|
|
27346
|
+
{ name: "sign_up", means: "An account was created." },
|
|
27347
|
+
{ name: "start_trial", means: "A trial began." },
|
|
27348
|
+
{ name: "subscribe", means: "A subscription started." },
|
|
27349
|
+
{ name: "qualified_lead", means: "Sales judged the lead worth working. The first event only you can send." },
|
|
27350
|
+
{ name: "opportunity", means: "A deal was opened against the lead." },
|
|
27351
|
+
{ name: "deal_won", means: "The deal closed. Send the money on it." },
|
|
27352
|
+
{ name: "deal_lost", means: "The deal was lost. Send it \u2014 a campaign that only reports wins reads as free." },
|
|
27353
|
+
{ name: "purchase", means: "Something was paid for. Send the money on it." },
|
|
27354
|
+
{ name: "refund", means: "Money went back. A separate event, never a negative amount." }
|
|
27355
|
+
];
|
|
27356
|
+
|
|
27357
|
+
// ../api/src/event-ingest/validation.ts
|
|
27358
|
+
var REJECTION_CODES = [
|
|
27359
|
+
"invalid_event_id",
|
|
27360
|
+
"invalid_name",
|
|
27361
|
+
"missing_identity",
|
|
27362
|
+
"invalid_timestamp",
|
|
27363
|
+
"invalid_value",
|
|
27364
|
+
"batch_too_large"
|
|
27365
|
+
];
|
|
27366
|
+
var EVENT_INGEST_BATCH_MAX = 500;
|
|
27367
|
+
var EVENT_INGEST_BODY_MAX_BYTES = 8 * 1024 * 1024;
|
|
27368
|
+
var EVENT_INGEST_REQUESTS_PER_MINUTE = 60;
|
|
27369
|
+
var FUTURE_TOLERANCE_MS = 5 * 6e4;
|
|
27370
|
+
var MAX_EVENT_NAME = 80;
|
|
27371
|
+
var REJECTION_MESSAGES = {
|
|
27372
|
+
invalid_event_id: "event_id, when sent, must be 1-128 characters of A-Z a-z 0-9 . _ : -",
|
|
27373
|
+
invalid_name: "event_name is required: up to 80 characters of letters, digits, spaces, . _ and -",
|
|
27374
|
+
missing_identity: "user_data named nobody, and no url named a page",
|
|
27375
|
+
invalid_timestamp: "event_time must be an ISO-8601 instant with an offset or Unix seconds, and not in the future",
|
|
27376
|
+
invalid_value: "value and currency go together: a non-negative decimal and an ISO-4217 code",
|
|
27377
|
+
batch_too_large: `a request carries at most ${EVENT_INGEST_BATCH_MAX} events and ${EVENT_INGEST_BODY_MAX_BYTES / (1024 * 1024)} MB`
|
|
27378
|
+
};
|
|
27379
|
+
|
|
27380
|
+
// ../api/src/event-ingest/fields.ts
|
|
27381
|
+
var BODY_MAX_MB = EVENT_INGEST_BODY_MAX_BYTES / (1024 * 1024);
|
|
27382
|
+
var FUTURE_TOLERANCE_MINUTES = FUTURE_TOLERANCE_MS / 6e4;
|
|
27383
|
+
var HEADER_FIELDS = [
|
|
27384
|
+
{ name: "Authorization", need: "required", takes: "Bearer <your sending key>" },
|
|
27385
|
+
{ name: "Content-Type", need: "required", takes: "application/json" },
|
|
27386
|
+
{
|
|
27387
|
+
name: "X-Baker-Mode",
|
|
27388
|
+
need: "optional",
|
|
27389
|
+
takes: "live (the default) or test.",
|
|
27390
|
+
// Deliberately says what a test send *is* rather than "see step 4". Every
|
|
27391
|
+
// note here is read twice — on this screen, where the step below explains
|
|
27392
|
+
// it in full, and inside the Markdown brief, where there are no steps and
|
|
27393
|
+
// a cross-reference points at nothing.
|
|
27394
|
+
note: "A test send is authenticated and validated identically and stores nothing. An unrecognised value is refused rather than assumed."
|
|
27395
|
+
}
|
|
27396
|
+
];
|
|
27397
|
+
var EVENT_FIELDS = [
|
|
27398
|
+
{
|
|
27399
|
+
name: "event_name",
|
|
27400
|
+
need: "required",
|
|
27401
|
+
takes: `What happened. Up to ${MAX_EVENT_NAME} characters of letters, digits, spaces, . _ and -`,
|
|
27402
|
+
note: "The only required field. Use one of the standard names below wherever one fits \u2014 anything else is yours to invent."
|
|
27403
|
+
},
|
|
27404
|
+
{
|
|
27405
|
+
name: "event_time",
|
|
27406
|
+
need: "optional",
|
|
27407
|
+
takes: "ISO-8601 with an offset (2026-09-08T13:23:41Z), or Unix seconds (1757337821).",
|
|
27408
|
+
note: `Left out, it is the moment we received it. Nothing is refused for being old \u2014 a backfill of last year's history is what this is for. More than ${FUTURE_TOLERANCE_MINUTES} minutes in the future is refused.`
|
|
27409
|
+
},
|
|
27410
|
+
{
|
|
27411
|
+
name: "event_id",
|
|
27412
|
+
need: "optional",
|
|
27413
|
+
takes: "Your own id for this event: 1\u2013128 characters of letters, digits, and . _ : -",
|
|
27414
|
+
note: "Send one only if you want it deduplicated \u2014 the same id twice is counted once. Send none and every post is its own event."
|
|
27415
|
+
},
|
|
27416
|
+
{
|
|
27417
|
+
name: "user_data",
|
|
27418
|
+
need: "required",
|
|
27419
|
+
takes: "Who it was about. At least one field below \u2014 this is the whole of how a server event finds the person who visited your pages.",
|
|
27420
|
+
note: "No field is required on its own and the block is: an event naming nobody is refused with missing_identity. The one exception is a page_view carrying a url, because a page serve is a fact about a page."
|
|
27421
|
+
},
|
|
27422
|
+
{
|
|
27423
|
+
name: "email",
|
|
27424
|
+
need: "optional",
|
|
27425
|
+
under: true,
|
|
27426
|
+
takes: "The address, or its SHA-256 digest if you already hash. Either \u2014 we tell them apart.",
|
|
27427
|
+
note: "Send this one if you can. It is the road that cannot silently fail: we normalise and hash it here, so it lands on exactly the person your pages already recorded."
|
|
27428
|
+
},
|
|
27429
|
+
{
|
|
27430
|
+
name: "phone",
|
|
27431
|
+
need: "optional",
|
|
27432
|
+
under: true,
|
|
27433
|
+
takes: "The number with its country code, or its digest. Same treatment as email."
|
|
27434
|
+
},
|
|
27435
|
+
{
|
|
27436
|
+
name: "external_id",
|
|
27437
|
+
need: "optional",
|
|
27438
|
+
under: true,
|
|
27439
|
+
takes: "Your own id for the person \u2014 a CRM contact key.",
|
|
27440
|
+
note: "Kept exactly as you send it, and hashed alongside so it joins: your own events to each other, and to a browser once one event carries it beside an email or a baker_id."
|
|
27441
|
+
},
|
|
27442
|
+
{
|
|
27443
|
+
name: "baker_id",
|
|
27444
|
+
need: "optional",
|
|
27445
|
+
under: true,
|
|
27446
|
+
takes: "The Baker ID one of our pages already gave this browser, if your system kept it.",
|
|
27447
|
+
note: "The strongest of all of them: it names the visit, so the event lands on that person's timeline rather than beside it."
|
|
27448
|
+
},
|
|
27449
|
+
{
|
|
27450
|
+
name: "the ad click ids",
|
|
27451
|
+
need: "optional",
|
|
27452
|
+
under: true,
|
|
27453
|
+
takes: "The click id that brought them, if you kept it, under the name the platform stamps on the link: gclid, wbraid, gbraid, fbclid, ttclid, twclid, li_fat_id, msclkid.",
|
|
27454
|
+
note: "A click id names a person too \u2014 it is enough on its own, and it attributes the event to the campaign that earned it."
|
|
27455
|
+
},
|
|
27456
|
+
{
|
|
27457
|
+
name: "fbp, fbc",
|
|
27458
|
+
need: "optional",
|
|
27459
|
+
under: true,
|
|
27460
|
+
takes: "Meta's own browser cookies (_fbp, _fbc), exactly as your site stored them.",
|
|
27461
|
+
note: "Enough on their own, and worth sending: they are what Meta matches a server event on. We read the click id out of an fbc for you."
|
|
27462
|
+
},
|
|
27463
|
+
{
|
|
27464
|
+
name: "the matching fields",
|
|
27465
|
+
need: "optional",
|
|
27466
|
+
under: true,
|
|
27467
|
+
takes: "first_name, last_name, date_of_birth, gender, city, region, postal_code, country \u2014 Meta's advanced matching and Google's enhanced conversions, in one block. Plain or already hashed.",
|
|
27468
|
+
note: "They name nobody here, so they never satisfy user_data on their own. They are kept so this event can be sent on to Meta or Google later without asking you for it all again."
|
|
27469
|
+
},
|
|
27470
|
+
{
|
|
27471
|
+
name: "url",
|
|
27472
|
+
need: "optional",
|
|
27473
|
+
takes: "The full page URL this happened on.",
|
|
27474
|
+
note: "Its query string is where campaign parameters and ad click ids are read from. Without it the event counts, and belongs to no page."
|
|
27475
|
+
},
|
|
27476
|
+
{
|
|
27477
|
+
name: "value",
|
|
27478
|
+
need: "optional",
|
|
27479
|
+
takes: "What it was worth: a decimal string or a JSON number. No thousands separator, no sign, no exponent.",
|
|
27480
|
+
note: 'Rounded to what the currency holds \u2014 1200.999 EUR is stored as 1201.00. "1,200" is refused.'
|
|
27481
|
+
},
|
|
27482
|
+
{
|
|
27483
|
+
name: "currency",
|
|
27484
|
+
need: "conditional",
|
|
27485
|
+
takes: "An ISO-4217 code, e.g. EUR. Required whenever value is present, and refused on its own."
|
|
27486
|
+
},
|
|
27487
|
+
{
|
|
27488
|
+
name: "properties",
|
|
27489
|
+
need: "optional",
|
|
27490
|
+
takes: "A flat object of strings \u2014 anything you want to break the event down by later."
|
|
27491
|
+
}
|
|
27492
|
+
];
|
|
27493
|
+
var RESPONSE_FIELDS = [
|
|
27494
|
+
{
|
|
27495
|
+
name: "mode",
|
|
27496
|
+
need: "required",
|
|
27497
|
+
takes: "live or test, echoed back \u2014 so a forgotten header is visible in the reply rather than in a report next week."
|
|
27498
|
+
},
|
|
27499
|
+
{ name: "received", need: "required", takes: "How many events we read, so you can detect a truncated body." },
|
|
27500
|
+
{ name: "accepted", need: "required", takes: "How many were counted." },
|
|
27501
|
+
{
|
|
27502
|
+
name: "rejected",
|
|
27503
|
+
need: "required",
|
|
27504
|
+
takes: "One entry per refused event: index, event_id, code, message.",
|
|
27505
|
+
note: "index is the position in your own array, and is the one to branch on \u2014 event_id is empty for an event that sent none."
|
|
27506
|
+
}
|
|
27507
|
+
];
|
|
27508
|
+
var REJECTION_CODE_LIST = REJECTION_CODES;
|
|
27509
|
+
var BATCH_LIMITS = {
|
|
27510
|
+
events: EVENT_INGEST_BATCH_MAX,
|
|
27511
|
+
megabytes: BODY_MAX_MB,
|
|
27512
|
+
requestsPerMinute: EVENT_INGEST_REQUESTS_PER_MINUTE
|
|
27513
|
+
};
|
|
27514
|
+
|
|
27515
|
+
// ../api/src/event-ingest/matching.ts
|
|
27516
|
+
var MATCH_FIELDS = [
|
|
27517
|
+
"first_name",
|
|
27518
|
+
"last_name",
|
|
27519
|
+
"date_of_birth",
|
|
27520
|
+
"gender",
|
|
27521
|
+
"city",
|
|
27522
|
+
"region",
|
|
27523
|
+
"postal_code",
|
|
27524
|
+
"country"
|
|
27525
|
+
];
|
|
27526
|
+
var MATCH_COOKIES = ["fbp", "fbc"];
|
|
27527
|
+
function tightened(value) {
|
|
27528
|
+
const stripped = value.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
|
27529
|
+
return stripped === "" ? null : stripped;
|
|
27530
|
+
}
|
|
27531
|
+
function loosened(value) {
|
|
27532
|
+
const collapsed = value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
27533
|
+
return collapsed === "" ? null : collapsed;
|
|
27534
|
+
}
|
|
27535
|
+
function birthDate(value) {
|
|
27536
|
+
const digits = value.replace(/\D/g, "");
|
|
27537
|
+
if (!/^\d{8}$/.test(digits)) return null;
|
|
27538
|
+
const month = Number(digits.slice(4, 6));
|
|
27539
|
+
const day = Number(digits.slice(6, 8));
|
|
27540
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
|
27541
|
+
return digits;
|
|
27542
|
+
}
|
|
27543
|
+
function genderLetter(value) {
|
|
27544
|
+
const first = value.trim().toLowerCase().charAt(0);
|
|
27545
|
+
return first === "f" || first === "m" ? first : null;
|
|
27546
|
+
}
|
|
27547
|
+
function countryCode(value) {
|
|
27548
|
+
const code = value.trim().toLowerCase();
|
|
27549
|
+
return /^[a-z]{2}$/.test(code) ? code : null;
|
|
27550
|
+
}
|
|
27551
|
+
var MATCH_SPECS = {
|
|
27552
|
+
first_name: { keep: "hashed", normalize: tightened, meta: "fn", google: "hashed_first_name" },
|
|
27553
|
+
last_name: { keep: "hashed", normalize: tightened, meta: "ln", google: "hashed_last_name" },
|
|
27554
|
+
// Meta only. Google's enhanced conversions have no date of birth, so there is
|
|
27555
|
+
// no destination wanting it in the clear and every reason not to keep one.
|
|
27556
|
+
date_of_birth: { keep: "hashed", normalize: birthDate, meta: "db", google: null },
|
|
27557
|
+
gender: { keep: "plain", normalize: genderLetter, meta: "ge", google: null },
|
|
27558
|
+
city: { keep: "plain", normalize: loosened, meta: "ct", google: "city" },
|
|
27559
|
+
region: { keep: "plain", normalize: loosened, meta: "st", google: "state" },
|
|
27560
|
+
postal_code: { keep: "plain", normalize: loosened, meta: "zp", google: "postal_code" },
|
|
27561
|
+
country: { keep: "plain", normalize: countryCode, meta: "country", google: "country_code" }
|
|
27562
|
+
};
|
|
27563
|
+
var MATCH_FIELD_FORWARDING = [
|
|
27564
|
+
{ field: "email", meta: "em", google: "hashed_email", hashed: true },
|
|
27565
|
+
{ field: "phone", meta: "ph", google: "hashed_phone_number", hashed: true },
|
|
27566
|
+
// Kept in the clear — see `resolveOwnId`. The digest is still computed and
|
|
27567
|
+
// still stored beside it, so a Meta forward has one; what changed is that the
|
|
27568
|
+
// sender's own key is now readable, which is the entire reason they sent it.
|
|
27569
|
+
{ field: "external_id", meta: "external_id", google: null, hashed: false },
|
|
27570
|
+
...MATCH_FIELDS.map((field) => ({
|
|
27571
|
+
field,
|
|
27572
|
+
meta: MATCH_SPECS[field].meta,
|
|
27573
|
+
google: MATCH_SPECS[field].google,
|
|
27574
|
+
hashed: MATCH_SPECS[field].keep === "hashed"
|
|
27575
|
+
})),
|
|
27576
|
+
{ field: "fbp", meta: "fbp", google: null, hashed: false },
|
|
27577
|
+
{ field: "fbc", meta: "fbc", google: null, hashed: false }
|
|
27578
|
+
];
|
|
27579
|
+
|
|
27580
|
+
// ../api/src/event-ingest/brief.ts
|
|
27581
|
+
var KEY_ENV_VAR = "BAKER_EVENTS_KEY";
|
|
27582
|
+
var KEY_PLACEHOLDER = "<your sending key>";
|
|
27583
|
+
var NEED_WORD = {
|
|
27584
|
+
required: "required",
|
|
27585
|
+
optional: "optional",
|
|
27586
|
+
conditional: "conditional"
|
|
27587
|
+
};
|
|
27588
|
+
function cell(text2) {
|
|
27589
|
+
return text2.replaceAll("<", "\\<").replaceAll("|", "\\|");
|
|
27590
|
+
}
|
|
27591
|
+
function table(fields) {
|
|
27592
|
+
const rows = fields.map((field) => {
|
|
27593
|
+
const name = field.under === true ? `\u21B3 \`${field.name}\`` : `\`${field.name}\``;
|
|
27594
|
+
const note = field.note === void 0 ? "" : `<br>${cell(field.note)}`;
|
|
27595
|
+
return `| ${name} | ${NEED_WORD[field.need]} | ${cell(field.takes)}${note} |`;
|
|
27596
|
+
});
|
|
27597
|
+
return ["| Field | | What it takes |", "| --- | --- | --- |", ...rows].join("\n");
|
|
27598
|
+
}
|
|
27599
|
+
function standardNames() {
|
|
27600
|
+
return [
|
|
27601
|
+
"| Name | Means |",
|
|
27602
|
+
"| --- | --- |",
|
|
27603
|
+
...STANDARD_EVENT_NAMES.map((standard) => `| \`${standard.name}\` | ${standard.means} |`)
|
|
27604
|
+
].join("\n");
|
|
27605
|
+
}
|
|
27606
|
+
function forwarding() {
|
|
27607
|
+
return [
|
|
27608
|
+
"| Field | Meta | Google | Held as |",
|
|
27609
|
+
"| --- | --- | --- | --- |",
|
|
27610
|
+
...MATCH_FIELD_FORWARDING.map(
|
|
27611
|
+
(row) => `| \`${row.field}\` | \`${row.meta}\` | ${row.google === null ? "\u2014" : `\`${row.google}\``} | ${row.hashed ? "SHA-256" : "plain text"} |`
|
|
27612
|
+
)
|
|
27613
|
+
].join("\n");
|
|
27614
|
+
}
|
|
27615
|
+
function ingestBrief(endpointUrl) {
|
|
27616
|
+
const { events, megabytes, requestsPerMinute } = BATCH_LIMITS;
|
|
27617
|
+
return `# Send events to Baker from your server
|
|
27618
|
+
|
|
27619
|
+
You are being handed this by somebody who wants an integration built against it. It is
|
|
27620
|
+
the complete contract for \`POST /v1/events\` on **their own Baker endpoint**, taken from
|
|
27621
|
+
the code that enforces it. Build from this document; you should not need to look
|
|
27622
|
+
anything else up.
|
|
27623
|
+
|
|
27624
|
+
Baker measures what happens on the pages it publishes. This endpoint is how everything
|
|
27625
|
+
that happens *away* from those pages \u2014 a deal closing in a CRM, a subscription starting,
|
|
27626
|
+
an order shipping \u2014 reaches the same reports, joined to the same person.
|
|
27627
|
+
|
|
27628
|
+
## Read this before you write anything
|
|
27629
|
+
|
|
27630
|
+
1. **Never put the sending key in source, and never paste it into a chat.** It is
|
|
27631
|
+
deliberately not in this document. Read it from \`${KEY_ENV_VAR}\` and ask the person
|
|
27632
|
+
for the value \u2014 they can copy it from Baker \u2192 Measure \u2192 Events \u2192 Sending events.
|
|
27633
|
+
2. **Send \`X-Baker-Mode: test\` while you build.** A test request is authenticated,
|
|
27634
|
+
parsed and validated in exactly the same way and answers in exactly the same shape,
|
|
27635
|
+
and **stores nothing**. Remove the header to go live. There is no separate test key
|
|
27636
|
+
and no separate endpoint, so nothing about the integration changes when it does.
|
|
27637
|
+
3. **A \`200\` does not mean it counted.** The body carries \`rejected[]\`, one entry per
|
|
27638
|
+
refused event. Read it and log it on every response. Throwing it away is the single
|
|
27639
|
+
most common way an integration against this endpoint is wrong and looks right.
|
|
27640
|
+
4. **Batch.** Up to ${events} events and ${megabytes} MB per request, and at most
|
|
27641
|
+
${requestsPerMinute} requests per minute for the whole company. One event per request
|
|
27642
|
+
works and will exhaust that budget on any real backfill. Every event is judged on its
|
|
27643
|
+
own, so one bad row is refused and the rest of the batch still counts.
|
|
27644
|
+
5. **Retry \`5xx\` and \`429\`, never \`4xx\`.** A \`4xx\` means the request is wrong and
|
|
27645
|
+
will be wrong again. If you retry, send the same \`event_id\` \u2014 that is what makes the
|
|
27646
|
+
retry count once.
|
|
27647
|
+
6. **A refund is its own event, not a negative amount.** \`value\` may not be negative.
|
|
27648
|
+
Send an event with its own name and let the reports subtract.
|
|
27649
|
+
7. **You cannot declare a conversion.** Whether an event counts as one is a decision the
|
|
27650
|
+
client makes in Baker, against the event's name, and it applies retroactively to
|
|
27651
|
+
everything already sent under that name. Nothing in the payload can assert it.
|
|
27652
|
+
8. **Ask, do not guess.** Which events matter and where the person's email lives in their
|
|
27653
|
+
system are things this document cannot tell you. Ask.
|
|
27654
|
+
|
|
27655
|
+
## Endpoint
|
|
27656
|
+
|
|
27657
|
+
\`\`\`
|
|
27658
|
+
POST ${endpointUrl}
|
|
27659
|
+
\`\`\`
|
|
27660
|
+
|
|
27661
|
+
### Headers
|
|
27662
|
+
|
|
27663
|
+
${table(HEADER_FIELDS)}
|
|
27664
|
+
|
|
27665
|
+
### Body
|
|
27666
|
+
|
|
27667
|
+
\`\`\`json
|
|
27668
|
+
{ "events": [ { "event_name": "..." } ] }
|
|
27669
|
+
\`\`\`
|
|
27670
|
+
|
|
27671
|
+
Up to ${events} events and ${megabytes} MB per request.
|
|
27672
|
+
|
|
27673
|
+
## The event
|
|
27674
|
+
|
|
27675
|
+
The field names are Meta's. If you have written an integration against Meta's Conversions
|
|
27676
|
+
API or Google Ads' offline conversion upload, this is the same payload: \`event_name\`,
|
|
27677
|
+
\`event_time\`, \`event_id\`, \`user_data\`, \`value\`, \`currency\`.
|
|
27678
|
+
|
|
27679
|
+
${table(EVENT_FIELDS)}
|
|
27680
|
+
|
|
27681
|
+
### Naming the person is the part that matters
|
|
27682
|
+
|
|
27683
|
+
\`user_data\` is required and every field in it is optional, which is not a contradiction:
|
|
27684
|
+
the event has to name **somebody**, and there are several ways to do it. An event that
|
|
27685
|
+
names nobody is refused with \`missing_identity\`.
|
|
27686
|
+
|
|
27687
|
+
Ranked by how well each works, best first:
|
|
27688
|
+
|
|
27689
|
+
1. \`baker_id\` \u2014 the id a Baker page already gave that browser. It names the *visit*, so
|
|
27690
|
+
the event lands on a real session's timeline. Send it if the client's system kept it.
|
|
27691
|
+
2. \`email\` or \`phone\` \u2014 the road that cannot silently fail. Send the plain value if you
|
|
27692
|
+
can: we normalise and hash it here under the same rule the browser uses, so it lands
|
|
27693
|
+
on exactly the person the pages already recorded. If policy forbids sending it plain,
|
|
27694
|
+
send the SHA-256 digest **in the same field** \u2014 we detect which it is. Hash the
|
|
27695
|
+
trimmed, lower-cased address; for a phone, digits with a leading \`+\`.
|
|
27696
|
+
3. \`external_id\` \u2014 the client's own key for the person, a CRM contact id. Stored as sent,
|
|
27697
|
+
and hashed alongside so it joins.
|
|
27698
|
+
On its own it joins that system's events to each other; send it **once** alongside an
|
|
27699
|
+
email or a \`baker_id\` and every later event carrying only the contact id joins the
|
|
27700
|
+
person too. That link is retroactive: it reaches back to everything already sent.
|
|
27701
|
+
4. A click id, \`fbp\` or \`fbc\` \u2014 names the click or the browser that brought them, which
|
|
27702
|
+
is what a report attributes the event to.
|
|
27703
|
+
|
|
27704
|
+
Send every one you have, not just the best. They cost nothing extra and each is a
|
|
27705
|
+
separate chance to match.
|
|
27706
|
+
|
|
27707
|
+
### The matching fields, and where they go afterwards
|
|
27708
|
+
|
|
27709
|
+
\`first_name\` \u2026 \`country\` never satisfy \`user_data\` on their own \u2014 they describe a
|
|
27710
|
+
person and name nobody. They are worth sending anyway: they are exactly what Meta's
|
|
27711
|
+
advanced matching and Google's enhanced conversions match on, and keeping them means the
|
|
27712
|
+
event can be forwarded later without going back to the client's CRM for it.
|
|
27713
|
+
|
|
27714
|
+
${forwarding()}
|
|
27715
|
+
|
|
27716
|
+
## Standard event names
|
|
27717
|
+
|
|
27718
|
+
Nothing is refused for being absent from this list \u2014 send whatever the business calls a
|
|
27719
|
+
thing. These are the spellings Baker publishes so that two companies measuring the same
|
|
27720
|
+
thing measure it under the same word. \`page_view\` is the one with behaviour: it becomes
|
|
27721
|
+
a page view rather than a named event, and it is the only event that may arrive without a
|
|
27722
|
+
person as long as it carries a \`url\`.
|
|
27723
|
+
|
|
27724
|
+
${standardNames()}
|
|
27725
|
+
|
|
27726
|
+
## The response
|
|
27727
|
+
|
|
27728
|
+
\`\`\`json
|
|
27729
|
+
{ "ok": true, "data": { "mode": "live", "received": 2, "accepted": 1,
|
|
27730
|
+
"rejected": [ { "index": 1, "event_id": "", "code": "invalid_value",
|
|
27731
|
+
"message": "value and currency go together: a non-negative decimal and an ISO-4217 code" } ] } }
|
|
27732
|
+
\`\`\`
|
|
27733
|
+
|
|
27734
|
+
${table(RESPONSE_FIELDS)}
|
|
27735
|
+
|
|
27736
|
+
\`code\` is a closed set \u2014 branch on it, not on \`message\`:
|
|
27737
|
+
|
|
27738
|
+
${REJECTION_CODE_LIST.map((code) => `- \`${code}\``).join("\n")}
|
|
27739
|
+
|
|
27740
|
+
There is no duplicate count, on purpose. A repeated \`event_id\` is accepted like any other
|
|
27741
|
+
event and counted **once** \u2014 one conversion, one event, its value added once \u2014 so a retry
|
|
27742
|
+
is safe and needs no branch of its own. Both requests are shown in Baker, with the later
|
|
27743
|
+
one marked "Already counted", so a retry is visible without being counted.
|
|
27744
|
+
|
|
27745
|
+
### HTTP statuses
|
|
27746
|
+
|
|
27747
|
+
| Status | Means | Do |
|
|
27748
|
+
| --- | --- | --- |
|
|
27749
|
+
| \`200\` | The request was read. Individual events may still have been refused. | Read \`rejected[]\`. |
|
|
27750
|
+
| \`400\` | The body is not the shape above \u2014 an unknown field, or the wrong JSON type. | Fix the payload. Do not retry. |
|
|
27751
|
+
| \`401\` | The key is missing, wrong, or not a sending key. | Fix the credential. Do not retry. |
|
|
27752
|
+
| \`413\` | Over ${events} events or ${megabytes} MB. | Split the batch and re-post. |
|
|
27753
|
+
| \`429\` | Over ${requestsPerMinute} requests a minute for this company. | Back off and retry. |
|
|
27754
|
+
| \`5xx\` | Nothing was written. | Retry with the same \`event_id\`s. |
|
|
27755
|
+
|
|
27756
|
+
## A request that works
|
|
27757
|
+
|
|
27758
|
+
\`\`\`bash
|
|
27759
|
+
${eventIngestCurl(endpointUrl, KEY_PLACEHOLDER)}
|
|
27760
|
+
\`\`\`
|
|
27761
|
+
|
|
27762
|
+
## Once it is sending
|
|
27763
|
+
|
|
27764
|
+
Every accepted event appears in Baker under **Measure \u2192 Events**, live, with what was
|
|
27765
|
+
sent. Test sends and refusals appear there too, filed separately and marked, with the
|
|
27766
|
+
payload \u2014 so "did my request arrive and what did it say" is answered on the screen rather
|
|
27767
|
+
than in the client's own logs. To make an event count as a conversion, the client marks
|
|
27768
|
+
it by name under **Conversions**.
|
|
27769
|
+
`;
|
|
27770
|
+
}
|
|
27771
|
+
|
|
27772
|
+
// ../api/src/event-ingest/preview.ts
|
|
27773
|
+
var MATCHING_KEYS = new Set(MATCH_FIELDS);
|
|
27774
|
+
var PREVIEWED_FIELDS = [
|
|
27775
|
+
"email",
|
|
27776
|
+
"phone",
|
|
27777
|
+
...MATCH_FIELDS.filter((field) => MATCH_SPECS[field].keep === "hashed")
|
|
27778
|
+
];
|
|
27779
|
+
|
|
27780
|
+
// ../api/src/event-ingest/vocabulary.ts
|
|
27781
|
+
var INGEST_CLICK_ID_PARAMS = [
|
|
27782
|
+
"gclid",
|
|
27783
|
+
"wbraid",
|
|
27784
|
+
"gbraid",
|
|
27785
|
+
"fbclid",
|
|
27786
|
+
"ttclid",
|
|
27787
|
+
"twclid",
|
|
27788
|
+
"li_fat_id",
|
|
27789
|
+
"msclkid"
|
|
27790
|
+
];
|
|
27791
|
+
|
|
27792
|
+
// ../api/src/event-ingest/wire.ts
|
|
27793
|
+
import { z as z31 } from "zod";
|
|
27794
|
+
var ingestUserDataSchema = z31.strictObject({
|
|
27795
|
+
/** A plain address, or its SHA-256 digest. Both accepted; we tell them apart. */
|
|
27796
|
+
email: z31.string().nullish(),
|
|
27797
|
+
/** A plain number, or its SHA-256 digest. Include the country code. */
|
|
27798
|
+
phone: z31.string().nullish(),
|
|
27799
|
+
/** Your own id for this person — a CRM contact id. Stored as you sent it, and hashed alongside so it joins. */
|
|
27800
|
+
external_id: z31.string().nullish(),
|
|
27801
|
+
/** The Baker ID this browser already carries, if your system kept it. */
|
|
27802
|
+
baker_id: z31.string().nullish(),
|
|
27803
|
+
...Object.fromEntries(
|
|
27804
|
+
[...MATCH_FIELDS, ...MATCH_COOKIES, ...INGEST_CLICK_ID_PARAMS].map((param) => [param, z31.string().nullish()])
|
|
27805
|
+
)
|
|
27806
|
+
});
|
|
27807
|
+
var ingestEventSchema = z31.strictObject({
|
|
27808
|
+
/**
|
|
27809
|
+
* What happened, in the sender's words. The only required field.
|
|
27810
|
+
*
|
|
27811
|
+
* Open vocabulary — nothing is checked against anything, exactly like a
|
|
27812
|
+
* `data-baker-*` event on a landing page. `STANDARD_EVENT_NAMES` publishes a
|
|
27813
|
+
* spelling for the common ones so two companies measuring the same thing
|
|
27814
|
+
* measure it under the same word, and `page_view` is the one name that also
|
|
27815
|
+
* carries behaviour.
|
|
27816
|
+
*
|
|
27817
|
+
* Whether an event **counts as a conversion** is not said here and cannot be:
|
|
27818
|
+
* a conversion is a name this company marked on its own Conversions screen,
|
|
27819
|
+
* matched at read time. A sender asserting one would be asserting something
|
|
27820
|
+
* about our configuration that they cannot see.
|
|
27821
|
+
*/
|
|
27822
|
+
event_name: z31.string(),
|
|
27823
|
+
/**
|
|
27824
|
+
* When it happened: an ISO-8601 instant with an offset, or Unix seconds.
|
|
27825
|
+
*
|
|
27826
|
+
* Optional, defaulting to when the request arrived. Meta and Google both
|
|
27827
|
+
* require their equivalent, and both are *offline conversion upload* APIs
|
|
27828
|
+
* where a past event is the only case there is. This door also serves the
|
|
27829
|
+
* live one — a CRM firing a webhook the moment a deal closes — and for that
|
|
27830
|
+
* sender the timestamp is the arrival time to within a second, so demanding
|
|
27831
|
+
* it buys nothing and loses the events of anyone who forgets it.
|
|
27832
|
+
*/
|
|
27833
|
+
event_time: z31.union([z31.string(), z31.number()]).nullish(),
|
|
27834
|
+
/**
|
|
27835
|
+
* Your own id for this event, and **only** if you want it deduplicated.
|
|
27836
|
+
*
|
|
27837
|
+
* Optional, exactly as it is on Meta's Conversions API: send the same id
|
|
27838
|
+
* twice and it is counted once, send none and every post is its own event.
|
|
27839
|
+
* Its *shape* is checked in `validateEventId`, not here, so a bad one is
|
|
27840
|
+
* `invalid_event_id` inside a `200` rather than a `400` for the other 199.
|
|
27841
|
+
*/
|
|
27842
|
+
event_id: z31.string().nullish(),
|
|
27843
|
+
/**
|
|
27844
|
+
* The page this happened on, as a full URL.
|
|
27845
|
+
*
|
|
27846
|
+
* Not in the minimal shape a server integration needs, and kept anyway: a
|
|
27847
|
+
* `page_view` with no URL names nothing, and the campaign parameters and
|
|
27848
|
+
* click ids a report is built on live in the query string. Everything else
|
|
27849
|
+
* treats it as optional context.
|
|
27850
|
+
*/
|
|
27851
|
+
url: z31.string().nullish(),
|
|
27852
|
+
user_data: ingestUserDataSchema.nullish(),
|
|
27853
|
+
/**
|
|
27854
|
+
* What it was worth, as a decimal string or a JSON number.
|
|
27855
|
+
*
|
|
27856
|
+
* Flat beside `currency` rather than a `{ amount, currency }` object of our
|
|
27857
|
+
* own: that is where Google Ads keeps `conversion_value`/`currency_code` and
|
|
27858
|
+
* where Meta keeps `value`/`currency` inside `custom_data`.
|
|
27859
|
+
*/
|
|
27860
|
+
value: z31.union([z31.string(), z31.number()]).nullish(),
|
|
27861
|
+
/** ISO-4217, required whenever `value` is present. */
|
|
27862
|
+
currency: z31.string().nullish(),
|
|
27863
|
+
/**
|
|
27864
|
+
* Flat detail about the event.
|
|
27865
|
+
*
|
|
27866
|
+
* Emails and phone numbers are masked out of the **values** before anything
|
|
27867
|
+
* is persisted — both into the event store and into the ingest log. Keys are
|
|
27868
|
+
* left alone: masking one would rename the field and could collide with a
|
|
27869
|
+
* sibling.
|
|
27870
|
+
*/
|
|
27871
|
+
properties: z31.record(z31.string(), z31.string()).nullish()
|
|
27872
|
+
});
|
|
27873
|
+
var ingestBatchSchema = z31.strictObject({
|
|
27874
|
+
events: z31.array(ingestEventSchema).min(1).max(EVENT_INGEST_BATCH_MAX)
|
|
27875
|
+
});
|
|
27876
|
+
var ingestRejectionSchema = z31.strictObject({
|
|
27877
|
+
index: z31.number().int().nonnegative(),
|
|
27878
|
+
event_id: z31.string(),
|
|
27879
|
+
code: z31.enum(REJECTION_CODES),
|
|
27880
|
+
message: z31.string()
|
|
27881
|
+
});
|
|
27882
|
+
var ingestResponseSchema = z31.strictObject({
|
|
27883
|
+
/**
|
|
27884
|
+
* How this request was treated, echoed from `X-Baker-Mode`.
|
|
27885
|
+
*
|
|
27886
|
+
* Here because the mode is a property of the request rather than of the key,
|
|
27887
|
+
* and the risk that comes with that is a forgotten header putting a rehearsal
|
|
27888
|
+
* into a client's real reports. Echoing it puts the answer to "did that
|
|
27889
|
+
* count?" in the reply to the request that asked, instead of in a report
|
|
27890
|
+
* somebody reads a week later.
|
|
27891
|
+
*/
|
|
27892
|
+
mode: z31.enum(INGEST_MODES),
|
|
27893
|
+
/** `events.length`, echoed back so a client can detect a truncated body. */
|
|
27894
|
+
received: z31.number().int().nonnegative(),
|
|
27895
|
+
accepted: z31.number().int().nonnegative(),
|
|
27896
|
+
rejected: z31.array(ingestRejectionSchema)
|
|
27897
|
+
});
|
|
27898
|
+
var ingestEnvelopeSchema = z31.object({ ok: z31.literal(true), data: ingestResponseSchema });
|
|
27899
|
+
|
|
26884
27900
|
// src/commands/analytics/index.ts
|
|
26885
27901
|
import { defineCommand as defineCommand95 } from "citty";
|
|
26886
27902
|
|
|
@@ -27019,7 +28035,18 @@ function adHierarchyHints(data, platform) {
|
|
|
27019
28035
|
`${platform} traffic carries no ${missing.map((entry) => AD_PARAM_ROLE_SPECS[entry.role].label.toLowerCase()).join(", ")}, so no report can say which ad sent a visit \u2014 only which platform. Add \`${suffix}\` to that account's tracking template. This cannot be backfilled: a parameter's values are only stored once it starts arriving.`
|
|
27020
28036
|
];
|
|
27021
28037
|
}
|
|
27022
|
-
function
|
|
28038
|
+
function sessionlessHint(data, origin) {
|
|
28039
|
+
if (data.totals.pageViews === 0 || data.totals.sessions > 0) return [];
|
|
28040
|
+
if (origin === "systems") {
|
|
28041
|
+
return [
|
|
28042
|
+
"No sessions, because this report is scoped to events a client's own server posted and those carry no visit \u2014 a session would be an invented one. Every per-visitor rate here is null for the same reason; read the counts, and drop --origin for anything about behaviour."
|
|
28043
|
+
];
|
|
28044
|
+
}
|
|
28045
|
+
return [
|
|
28046
|
+
"Pages were served but no sessions were counted \u2014 visitors are declining analytics consent, so traffic is measurable and per-visitor behaviour is not. Say so rather than reporting zero visitors."
|
|
28047
|
+
];
|
|
28048
|
+
}
|
|
28049
|
+
function buildAnalyticsHints(data, platform, origin) {
|
|
27023
28050
|
const hints2 = [];
|
|
27024
28051
|
for (const funnel of data.funnels ?? []) {
|
|
27025
28052
|
const worst = funnel.worstStep;
|
|
@@ -27039,11 +28066,7 @@ function buildAnalyticsHints(data, platform) {
|
|
|
27039
28066
|
"Visitors leave in under ten seconds on average \u2014 that is usually a mismatch between the ad and the page, not a form problem. Check the top sources against the page's headline."
|
|
27040
28067
|
);
|
|
27041
28068
|
}
|
|
27042
|
-
|
|
27043
|
-
hints2.push(
|
|
27044
|
-
"Pages were served but no sessions were counted \u2014 visitors are declining analytics consent, so traffic is measurable and per-visitor behaviour is not. Say so rather than reporting zero visitors."
|
|
27045
|
-
);
|
|
27046
|
-
}
|
|
28069
|
+
hints2.push(...sessionlessHint(data, origin));
|
|
27047
28070
|
hints2.push(...shapeHints(data));
|
|
27048
28071
|
hints2.push(...adHierarchyHints(data, platform));
|
|
27049
28072
|
if ((data.topCampaigns ?? []).length === 0 && (data.topSources ?? []).length > 0) {
|
|
@@ -27209,7 +28232,7 @@ function isTabular(rows) {
|
|
|
27209
28232
|
)
|
|
27210
28233
|
);
|
|
27211
28234
|
}
|
|
27212
|
-
function
|
|
28235
|
+
function cell2(value, key) {
|
|
27213
28236
|
if (value === null || value === void 0) return "";
|
|
27214
28237
|
if (Array.isArray(value)) return value.join(" ");
|
|
27215
28238
|
if (typeof value === "number" && !Number.isInteger(value)) {
|
|
@@ -27265,12 +28288,12 @@ function columnsOf(rows) {
|
|
|
27265
28288
|
}
|
|
27266
28289
|
return seen.filter((key) => rows.some((row) => !isAbsent(row[key]) && row[key] !== null && row[key] !== void 0)).sort(byIdentityFirst);
|
|
27267
28290
|
}
|
|
27268
|
-
function
|
|
28291
|
+
function table2(rows) {
|
|
27269
28292
|
const cols = columnsOf(rows);
|
|
27270
28293
|
if (cols.length === 0) return "";
|
|
27271
28294
|
const head = `| ${cols.join(" | ")} |`;
|
|
27272
28295
|
const rule = `| ${cols.map(() => "---").join(" | ")} |`;
|
|
27273
|
-
const body = rows.map((row) => `| ${cols.map((col) =>
|
|
28296
|
+
const body = rows.map((row) => `| ${cols.map((col) => cell2(row[col], col)).join(" | ")} |`);
|
|
27274
28297
|
return [head, rule, ...body].join("\n");
|
|
27275
28298
|
}
|
|
27276
28299
|
function isRowList(value) {
|
|
@@ -27287,11 +28310,11 @@ function nestedBlock(name, rows) {
|
|
|
27287
28310
|
const parents = rows.map(
|
|
27288
28311
|
(row) => Object.fromEntries(Object.entries(row).filter(([, value]) => !isRowList(value) && !isPlainRow(value)))
|
|
27289
28312
|
);
|
|
27290
|
-
const parts = [`### ${name}`,
|
|
28313
|
+
const parts = [`### ${name}`, table2(parents)];
|
|
27291
28314
|
rows.forEach((row, index) => {
|
|
27292
28315
|
for (const [key, value] of Object.entries(row)) {
|
|
27293
|
-
if (isRowList(value)) parts.push(`#### ${name}[${labelOf(row, index)}].${key}`,
|
|
27294
|
-
else if (isPlainRow(value)) parts.push(`#### ${name}[${labelOf(row, index)}].${key}`,
|
|
28316
|
+
if (isRowList(value)) parts.push(`#### ${name}[${labelOf(row, index)}].${key}`, table2(value));
|
|
28317
|
+
else if (isPlainRow(value)) parts.push(`#### ${name}[${labelOf(row, index)}].${key}`, table2([value]));
|
|
27295
28318
|
}
|
|
27296
28319
|
});
|
|
27297
28320
|
return parts.filter((part) => part !== "").join("\n");
|
|
@@ -27303,7 +28326,7 @@ function block(name, value) {
|
|
|
27303
28326
|
if (Array.isArray(value)) {
|
|
27304
28327
|
if (value.length === 0) return "";
|
|
27305
28328
|
if (isTabular(value)) return `### ${name}
|
|
27306
|
-
${
|
|
28329
|
+
${table2(value)}`;
|
|
27307
28330
|
if (isRowList(value)) return nestedBlock(name, value);
|
|
27308
28331
|
return `### ${name}
|
|
27309
28332
|
${JSON.stringify(value)}`;
|
|
@@ -27313,13 +28336,13 @@ ${JSON.stringify(value)}`;
|
|
|
27313
28336
|
if (entries.length === 0) return "";
|
|
27314
28337
|
if (entries.every(([, v]) => v === null || typeof v !== "object")) {
|
|
27315
28338
|
return `### ${name}
|
|
27316
|
-
${entries.map(([k, v]) => `- ${k}: ${v === null ? "null" :
|
|
28339
|
+
${entries.map(([k, v]) => `- ${k}: ${v === null ? "null" : cell2(v, k)}`).join("\n")}`;
|
|
27317
28340
|
}
|
|
27318
28341
|
return `### ${name}
|
|
27319
28342
|
${JSON.stringify(value)}`;
|
|
27320
28343
|
}
|
|
27321
28344
|
return `### ${name}
|
|
27322
|
-
${
|
|
28345
|
+
${cell2(value)}`;
|
|
27323
28346
|
}
|
|
27324
28347
|
function renderMarkdown3(envelope) {
|
|
27325
28348
|
const parts = [];
|
|
@@ -27425,6 +28448,11 @@ var SHARED_ARGS = {
|
|
|
27425
28448
|
description: "Read the whole report in one ad platform's vocabulary, and from its traffic only: google, meta, microsoft, linkedin, tiktok, reddit, pinterest, snapchat. Two effects at once \u2014 --role resolves against the spellings that platform uses (`cid` is TikTok's ad and nobody else's campaign) and the report narrows to the visits that arrived from it. Start here when comparing platforms, because without it every dimension is the union across all of them and one parameter can land under two roles",
|
|
27426
28449
|
required: false
|
|
27427
28450
|
},
|
|
28451
|
+
origin: {
|
|
28452
|
+
type: "string",
|
|
28453
|
+
description: "Read one collector only: `site` for what Baker measured on its own pages, `systems` for what the client's own server posted to POST /v1/events. Omit it for both, which is the default and the honest superset. Reach for it when a number moved and nothing on the pages changed \u2014 a CRM replaying history lands in the same totals as a visitor, and this is the only thing that separates them. Honoured by overview, traffic, events and conversions; every session-scoped report ignores it, because a server-sent event carries no visit to be counted in",
|
|
28454
|
+
required: false
|
|
28455
|
+
},
|
|
27428
28456
|
output: {
|
|
27429
28457
|
type: "string",
|
|
27430
28458
|
description: "json (default) | md. `md` renders each block as a table \u2014 one header instead of the keys repeated on every row \u2014 which is 20-50% smaller than the JSON on a typical report and more on a long breakdown. Reach for it whenever you are going to read the numbers rather than pick one field out of them. Both carry the same warnings and hints",
|
|
@@ -27480,13 +28508,26 @@ function requestBody(args, options) {
|
|
|
27480
28508
|
// everything.
|
|
27481
28509
|
adValue: args.role !== void 0 && args.value !== void 0 ? String(args.value) : void 0,
|
|
27482
28510
|
adPlatform: args.platform ? String(args.platform) : void 0,
|
|
28511
|
+
eventOrigin: args.origin ? String(args.origin) : void 0,
|
|
27483
28512
|
...pageOf(args)
|
|
27484
28513
|
};
|
|
27485
28514
|
return Object.fromEntries(
|
|
27486
28515
|
Object.entries(candidates).filter(([key, value]) => value !== void 0 && (value !== "" || key === "adValue"))
|
|
27487
28516
|
);
|
|
27488
28517
|
}
|
|
28518
|
+
var EVENT_ORIGINS = ["site", "systems"];
|
|
27489
28519
|
async function runPreset(args, options) {
|
|
28520
|
+
const origin = args.origin === void 0 ? "" : String(args.origin);
|
|
28521
|
+
if (origin !== "" && !EVENT_ORIGINS.includes(origin)) {
|
|
28522
|
+
writeJsonEnvelope({
|
|
28523
|
+
ok: false,
|
|
28524
|
+
error: {
|
|
28525
|
+
code: "VALIDATION_ERROR",
|
|
28526
|
+
message: `Unknown --origin "${origin}". Use one of: ${EVENT_ORIGINS.join(", ")}, or omit it for both.`
|
|
28527
|
+
}
|
|
28528
|
+
});
|
|
28529
|
+
process.exit(1);
|
|
28530
|
+
}
|
|
27490
28531
|
try {
|
|
27491
28532
|
const response = await apiPost("/api/analytics/query", requestBody(args, options));
|
|
27492
28533
|
const requestedPlatform = args.platform === void 0 ? "" : String(args.platform);
|
|
@@ -27508,7 +28549,7 @@ async function runPreset(args, options) {
|
|
|
27508
28549
|
// only speak about the inventory it was actually given. `adParams` is one
|
|
27509
28550
|
// platform's list; a hint drawn from it without knowing which platform
|
|
27510
28551
|
// would be a claim about Meta made from Google's URLs.
|
|
27511
|
-
...buildAnalyticsHints(response.data, adPlatformScope),
|
|
28552
|
+
...buildAnalyticsHints(response.data, adPlatformScope, origin === "" ? void 0 : origin),
|
|
27512
28553
|
...missingBreakdownHint(response.data, {
|
|
27513
28554
|
eventName: options.eventName,
|
|
27514
28555
|
property: options.property
|
|
@@ -27909,6 +28950,102 @@ function parseList(raw) {
|
|
|
27909
28950
|
const out = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
27910
28951
|
return out.length > 0 ? out : void 0;
|
|
27911
28952
|
}
|
|
28953
|
+
var arrivalsCommand = (() => {
|
|
28954
|
+
const args = {
|
|
28955
|
+
mode: {
|
|
28956
|
+
type: "string",
|
|
28957
|
+
description: "live | test. Rehearsals (X-Baker-Mode: test) are accepted and write nothing, so they never reach a report \u2014 filter to them to confirm a rehearsal landed, or to live to see only what counted",
|
|
28958
|
+
required: false
|
|
28959
|
+
},
|
|
28960
|
+
outcome: {
|
|
28961
|
+
type: "string",
|
|
28962
|
+
description: "accepted | rejected. Start with rejected: it is the half the sender's own logs threw away, and the reason to open this at all",
|
|
28963
|
+
required: false
|
|
28964
|
+
},
|
|
28965
|
+
event: {
|
|
28966
|
+
type: "string",
|
|
28967
|
+
description: "Only this event_name, for a company posting several kinds",
|
|
28968
|
+
required: false
|
|
28969
|
+
},
|
|
28970
|
+
limit: {
|
|
28971
|
+
type: "string",
|
|
28972
|
+
description: `How many to return, newest first (default 25, max ${INGEST_ARRIVALS_MAX})`,
|
|
28973
|
+
required: false
|
|
28974
|
+
},
|
|
28975
|
+
full: {
|
|
28976
|
+
type: "boolean",
|
|
28977
|
+
description: "Also return the masked request body and the per-field identity previews. Reach for it only once the codes have not explained it \u2014 it is most of the bytes",
|
|
28978
|
+
required: false
|
|
28979
|
+
}
|
|
28980
|
+
};
|
|
28981
|
+
registerSchema({
|
|
28982
|
+
command: "analytics.arrivals",
|
|
28983
|
+
description: "What a client's own server posted to POST /v1/events in the last 48 hours, and why any of it was refused",
|
|
28984
|
+
args
|
|
28985
|
+
});
|
|
28986
|
+
return defineCommand95({
|
|
28987
|
+
meta: {
|
|
28988
|
+
name: "arrivals",
|
|
28989
|
+
description: `Every request a client's own server posted to POST /v1/events in the last 48 hours \u2014 accepted, refused or rehearsed. The one place a server-side integration can be debugged.
|
|
28990
|
+
|
|
28991
|
+
Read this FIRST whenever somebody says events are being sent and nothing shows up. Four different causes look identical from the sender's side and only this tells them apart:
|
|
28992
|
+
the request never reached us \u2014 nothing here at all: the key, the URL, or their own dispatch
|
|
28993
|
+
it was refused \u2014 rejectCode says which rule, rejectMessage says what the field had to be
|
|
28994
|
+
it was a rehearsal \u2014 mode: test is accepted, answered identically, and writes nothing on purpose
|
|
28995
|
+
it landed \u2014 then the question is a report's, not the endpoint's
|
|
28996
|
+
|
|
28997
|
+
Examples:
|
|
28998
|
+
baker analytics arrivals \u2014 the last 25, newest first
|
|
28999
|
+
baker analytics arrivals --outcome rejected \u2014 only the refusals, which is usually the whole answer
|
|
29000
|
+
baker analytics arrivals --mode test \u2014 did the rehearsal they just ran actually arrive
|
|
29001
|
+
baker analytics arrivals --event deal_won \u2014 one kind only
|
|
29002
|
+
baker analytics arrivals --full --limit 5 \u2014 the masked body, when the codes have not explained it`
|
|
29003
|
+
},
|
|
29004
|
+
args,
|
|
29005
|
+
run: async ({ args: raw }) => {
|
|
29006
|
+
try {
|
|
29007
|
+
const limit = raw.limit === void 0 ? void 0 : Number(raw.limit);
|
|
29008
|
+
const response = await apiPost("/api/analytics/arrivals", {
|
|
29009
|
+
...raw.mode ? { mode: String(raw.mode) } : {},
|
|
29010
|
+
...raw.outcome ? { outcome: String(raw.outcome) } : {},
|
|
29011
|
+
...raw.event ? { eventName: String(raw.event) } : {},
|
|
29012
|
+
...limit !== void 0 && Number.isFinite(limit) ? { limit } : {},
|
|
29013
|
+
...raw.full ? { full: true } : {}
|
|
29014
|
+
});
|
|
29015
|
+
writeJsonEnvelope(response);
|
|
29016
|
+
} catch (err) {
|
|
29017
|
+
handleError(err);
|
|
29018
|
+
}
|
|
29019
|
+
}
|
|
29020
|
+
});
|
|
29021
|
+
})();
|
|
29022
|
+
var sendingEventsCommand = (() => {
|
|
29023
|
+
registerSchema({
|
|
29024
|
+
command: "analytics.sending-events",
|
|
29025
|
+
description: "The POST /v1/events contract as Markdown, for whoever is writing the integration",
|
|
29026
|
+
args: {}
|
|
29027
|
+
});
|
|
29028
|
+
return defineCommand95({
|
|
29029
|
+
meta: {
|
|
29030
|
+
name: "sending-events",
|
|
29031
|
+
description: `The complete contract for sending events into Baker from a client's own systems \u2014 a CRM marking a deal won, a back office recording a refund \u2014 as Markdown you can hand straight to whoever writes the integration.
|
|
29032
|
+
|
|
29033
|
+
Use it whenever a client wants an outcome that happens AFTER the lead leaves the page to show up in Baker. Every bound, every rejection code and every rule is read off the code that enforces it, so it cannot go stale; the six mistakes that all produce a 200 are called out before the reader starts rather than explained afterwards.
|
|
29034
|
+
|
|
29035
|
+
It contains no key \u2014 the brief says to read one from an environment variable, and the key itself is minted in the dashboard under Settings \u2192 API keys. Never put one in a file or a message.
|
|
29036
|
+
|
|
29037
|
+
Examples:
|
|
29038
|
+
baker analytics sending-events \u2014 the contract, as Markdown
|
|
29039
|
+
baker analytics arrivals --outcome rejected \u2014 after they have wired it up, what is being refused`
|
|
29040
|
+
},
|
|
29041
|
+
args: {},
|
|
29042
|
+
run: () => {
|
|
29043
|
+
const apiUrl = getEnv().BAKER_API_URL;
|
|
29044
|
+
process.stdout.write(`${ingestBrief(eventIngestUrl(apiUrl, apiUrl))}
|
|
29045
|
+
`);
|
|
29046
|
+
}
|
|
29047
|
+
});
|
|
29048
|
+
})();
|
|
27912
29049
|
var analyticsCommand2 = defineCommand95({
|
|
27913
29050
|
meta: {
|
|
27914
29051
|
name: "analytics",
|
|
@@ -27949,6 +29086,9 @@ Examples:
|
|
|
27949
29086
|
baker analytics map --platform google --remove kw \u2014 take that answer back
|
|
27950
29087
|
baker analytics delivery --page 2 --page-size 50 \u2014 the next page of a long list
|
|
27951
29088
|
baker analytics overview --compare \u2014 this window against the one before it
|
|
29089
|
+
baker analytics sending-events \u2014 the contract for a client's server to post its own events
|
|
29090
|
+
baker analytics arrivals --outcome rejected \u2014 what their server posted that we refused, and why
|
|
29091
|
+
baker analytics events --origin systems \u2014 the events their systems reported, not ours
|
|
27952
29092
|
Full guide: __tooling__/docs/tools/baker/analytics.md`
|
|
27953
29093
|
},
|
|
27954
29094
|
subCommands: {
|
|
@@ -27972,6 +29112,8 @@ Full guide: __tooling__/docs/tools/baker/analytics.md`
|
|
|
27972
29112
|
conversions: conversionsCommand3,
|
|
27973
29113
|
map: mapCommand,
|
|
27974
29114
|
tracking: trackingCommand,
|
|
29115
|
+
arrivals: arrivalsCommand,
|
|
29116
|
+
"sending-events": sendingEventsCommand,
|
|
27975
29117
|
presets: presetsCommand
|
|
27976
29118
|
}
|
|
27977
29119
|
});
|
|
@@ -30296,7 +31438,7 @@ import { toCardinal as nwKo } from "n2words/ko-KR";
|
|
|
30296
31438
|
import { toCardinal as nwNl } from "n2words/nl-NL";
|
|
30297
31439
|
import { toCardinal as nwPl } from "n2words/pl-PL";
|
|
30298
31440
|
import { toCardinal as nwPt } from "n2words/pt-PT";
|
|
30299
|
-
import { z as
|
|
31441
|
+
import { z as z32 } from "zod";
|
|
30300
31442
|
|
|
30301
31443
|
// src/engine/scaffold/lib/shoot-modes.ts
|
|
30302
31444
|
var SHOOT_MODES = [
|
|
@@ -30646,71 +31788,71 @@ function trimArgs(durationS, offsetS = 0, dims) {
|
|
|
30646
31788
|
"{{out.video}}"
|
|
30647
31789
|
];
|
|
30648
31790
|
}
|
|
30649
|
-
var FrameAsset =
|
|
30650
|
-
var DialogueLine =
|
|
30651
|
-
speaker:
|
|
30652
|
-
line:
|
|
31791
|
+
var FrameAsset = z32.object({ url: z32.string().optional() }).loose().optional();
|
|
31792
|
+
var DialogueLine = z32.object({
|
|
31793
|
+
speaker: z32.string().optional(),
|
|
31794
|
+
line: z32.string().optional(),
|
|
30653
31795
|
// Absolute seconds on the source timeline (the deconstruct emits both).
|
|
30654
|
-
start_s:
|
|
30655
|
-
end_s:
|
|
30656
|
-
delivery:
|
|
30657
|
-
voice_description:
|
|
31796
|
+
start_s: z32.number().optional(),
|
|
31797
|
+
end_s: z32.number().optional(),
|
|
31798
|
+
delivery: z32.string().optional(),
|
|
31799
|
+
voice_description: z32.string().optional(),
|
|
30658
31800
|
// DECON-supplied: is this speaker's FACE visibly speaking in THIS scene? Element
|
|
30659
31801
|
// presence alone can't answer that — a founder pictured in a polaroid close-up is
|
|
30660
31802
|
// "present" yet the line is voiceover, and treating it as on-camera produced a
|
|
30661
31803
|
// native Seedance lip-sync clip of a still photograph. `false` pins the line to
|
|
30662
31804
|
// the VO path; absent keeps the presence-based decision (old blueprints).
|
|
30663
|
-
on_camera:
|
|
31805
|
+
on_camera: z32.boolean().optional()
|
|
30664
31806
|
}).loose();
|
|
30665
|
-
var Sfx =
|
|
30666
|
-
at_s:
|
|
30667
|
-
duration_s:
|
|
30668
|
-
sound_effect_prompt:
|
|
30669
|
-
description:
|
|
31807
|
+
var Sfx = z32.object({
|
|
31808
|
+
at_s: z32.number().optional(),
|
|
31809
|
+
duration_s: z32.number().optional(),
|
|
31810
|
+
sound_effect_prompt: z32.string().optional(),
|
|
31811
|
+
description: z32.string().optional()
|
|
30670
31812
|
}).loose();
|
|
30671
|
-
var CompositionRegion =
|
|
31813
|
+
var CompositionRegion = z32.object({
|
|
30672
31814
|
// full | top | bottom | left | right | inset
|
|
30673
|
-
panel:
|
|
31815
|
+
panel: z32.string().optional(),
|
|
30674
31816
|
// 9-grid anchor for an `inset` presenter box.
|
|
30675
|
-
position:
|
|
30676
|
-
is_presenter:
|
|
31817
|
+
position: z32.string().optional(),
|
|
31818
|
+
is_presenter: z32.boolean().optional(),
|
|
30677
31819
|
// The cast id shown/speaking in this region (routes lip-sync + element refs).
|
|
30678
|
-
cast_ref:
|
|
31820
|
+
cast_ref: z32.string().optional(),
|
|
30679
31821
|
// What the region's content IS: camera | screen_capture | static_graphic |
|
|
30680
31822
|
// generated. Authoritative for routing when present (regex-over-prose fallback
|
|
30681
31823
|
// otherwise): screen_capture/static_graphic are rebuilt from REAL surfaces on the
|
|
30682
31824
|
// overlay layer, never AI-generated.
|
|
30683
|
-
kind:
|
|
31825
|
+
kind: z32.string().optional(),
|
|
30684
31826
|
// Opaque id naming the SPECIFIC on-screen document/note/app-state this
|
|
30685
31827
|
// screen_capture region shows. Two scenes share it only when they show the SAME
|
|
30686
31828
|
// recording continuing (scrolling/typing/waiting within it) — a genuinely
|
|
30687
31829
|
// DIFFERENT document/note/recording (a source video splicing two screen captures)
|
|
30688
31830
|
// gets a different id. Breaks a persistent-layout run into separate surface stubs
|
|
30689
31831
|
// instead of asking the operator for one screenshot that can't cover both.
|
|
30690
|
-
surface_id:
|
|
31832
|
+
surface_id: z32.string().optional(),
|
|
30691
31833
|
// Camera bubble(s)/inset(s) embedded INSIDE this region's surface (a Loom-style
|
|
30692
31834
|
// presenter bubble inside a screen recording) — video-in-video the reproduction
|
|
30693
31835
|
// must re-composite, not paint into the surface.
|
|
30694
|
-
nested:
|
|
30695
|
-
summary:
|
|
30696
|
-
frame_prompt:
|
|
30697
|
-
motion_prompt:
|
|
31836
|
+
nested: z32.array(z32.object({}).loose()).optional(),
|
|
31837
|
+
summary: z32.string().optional(),
|
|
31838
|
+
frame_prompt: z32.string().optional(),
|
|
31839
|
+
motion_prompt: z32.string().optional()
|
|
30698
31840
|
}).loose();
|
|
30699
|
-
var SceneComposition =
|
|
31841
|
+
var SceneComposition = z32.object({
|
|
30700
31842
|
// full_frame (default) | split_screen | pip | keyed_overlay
|
|
30701
|
-
layout:
|
|
31843
|
+
layout: z32.string().optional(),
|
|
30702
31844
|
// split_screen only: vertical (top/bottom) | horizontal (left/right).
|
|
30703
|
-
split_axis:
|
|
30704
|
-
regions:
|
|
31845
|
+
split_axis: z32.string().optional(),
|
|
31846
|
+
regions: z32.array(CompositionRegion).optional()
|
|
30705
31847
|
}).loose();
|
|
30706
|
-
var CameraMotion =
|
|
30707
|
-
var TranscriptWord =
|
|
30708
|
-
var Scene =
|
|
30709
|
-
start_s:
|
|
30710
|
-
end_s:
|
|
30711
|
-
duration_s:
|
|
30712
|
-
summary:
|
|
30713
|
-
action_detail:
|
|
31848
|
+
var CameraMotion = z32.object({ movement: z32.string().optional(), detail: z32.string().optional() }).loose();
|
|
31849
|
+
var TranscriptWord = z32.object({ text: z32.string().optional() }).loose();
|
|
31850
|
+
var Scene = z32.object({
|
|
31851
|
+
start_s: z32.number().optional(),
|
|
31852
|
+
end_s: z32.number().optional(),
|
|
31853
|
+
duration_s: z32.number().optional(),
|
|
31854
|
+
summary: z32.string().optional(),
|
|
31855
|
+
action_detail: z32.string().optional(),
|
|
30714
31856
|
// The scene's spatial layout. Absent/full_frame ⇒ one uncut shot (default path).
|
|
30715
31857
|
// A layered layout (split_screen/pip/keyed_overlay) with regions ⇒ the scaffold
|
|
30716
31858
|
// builds one clip per region and stacks/overlays them into the scene picture.
|
|
@@ -30718,82 +31860,82 @@ var Scene = z30.object({
|
|
|
30718
31860
|
// The capture "look" for this scene — selected from the ad-native shoot-mode
|
|
30719
31861
|
// grammar (see lib/shoot-modes.ts). When absent the scaffold auto-derives a
|
|
30720
31862
|
// UGC/product mode; a human can override per scene by setting this.
|
|
30721
|
-
shoot_mode:
|
|
31863
|
+
shoot_mode: z32.string().optional(),
|
|
30722
31864
|
// Diegetic ambient the clip's native audio should carry (no music). When
|
|
30723
31865
|
// absent the scene falls back to its shoot mode's default ambience.
|
|
30724
|
-
ambient:
|
|
31866
|
+
ambient: z32.string().optional(),
|
|
30725
31867
|
camera_motion: CameraMotion.optional(),
|
|
30726
|
-
start_frame_prompt:
|
|
30727
|
-
end_frame_prompt:
|
|
30728
|
-
motion_prompt:
|
|
31868
|
+
start_frame_prompt: z32.string().optional(),
|
|
31869
|
+
end_frame_prompt: z32.string().optional(),
|
|
31870
|
+
motion_prompt: z32.string().optional(),
|
|
30729
31871
|
// The scene's role in the ad's persuasion arc (DECON-supplied); drives the
|
|
30730
31872
|
// script re-craft checklist. Inferred from position when absent.
|
|
30731
|
-
narrative_role:
|
|
31873
|
+
narrative_role: z32.string().optional(),
|
|
30732
31874
|
// DECON-supplied on the HOOK scene: the engineered physical/emotional state that
|
|
30733
31875
|
// makes the first frame stop the scroll (sweaty/breathless/urgent …). Injected
|
|
30734
31876
|
// into the hook's start-frame description so the generator renders that state,
|
|
30735
31877
|
// not a calm influencer (CCA-11).
|
|
30736
|
-
hook_mechanic:
|
|
31878
|
+
hook_mechanic: z32.object({ mechanic: z32.string().optional(), why_it_stops_scroll: z32.string().optional() }).loose().optional(),
|
|
30737
31879
|
// DECON-supplied per-scene location (so a gym hook isn't flattened to "home").
|
|
30738
|
-
scene_setting:
|
|
31880
|
+
scene_setting: z32.string().optional(),
|
|
30739
31881
|
// How this scene cuts to the next (DECON-supplied). A recognized non-cut type
|
|
30740
31882
|
// (fade/whip/zoom/dissolve/swipe) is reproduced as an ffmpeg xfade at the
|
|
30741
31883
|
// boundary; cut/match_cut/none/other stay hard cuts. The last scene's value is
|
|
30742
31884
|
// ignored (nothing follows it).
|
|
30743
|
-
transition_out:
|
|
30744
|
-
dialogue:
|
|
30745
|
-
sfx:
|
|
30746
|
-
overlays:
|
|
30747
|
-
floating_elements:
|
|
31885
|
+
transition_out: z32.object({ type: z32.string().optional(), description: z32.string().optional() }).loose().optional(),
|
|
31886
|
+
dialogue: z32.array(DialogueLine).optional(),
|
|
31887
|
+
sfx: z32.array(Sfx).optional(),
|
|
31888
|
+
overlays: z32.array(z32.unknown()).optional(),
|
|
31889
|
+
floating_elements: z32.array(z32.unknown()).optional(),
|
|
30748
31890
|
// DECON-supplied: how much the picture itself moves within the shot. Gates the
|
|
30749
31891
|
// flash-hold optimization — a sub-2s b-roll flash with REAL subject motion
|
|
30750
31892
|
// (pouring, spreading, hands working) must stay a real clip; freezing it turns
|
|
30751
31893
|
// a montage into a slideshow. Absent (old blueprints) keeps the cheap still.
|
|
30752
|
-
motion_level:
|
|
30753
|
-
transcript_slice:
|
|
31894
|
+
motion_level: z32.enum(["static", "subtle", "dynamic"]).optional(),
|
|
31895
|
+
transcript_slice: z32.array(TranscriptWord).optional(),
|
|
30754
31896
|
start_frame_asset: FrameAsset,
|
|
30755
31897
|
end_frame_asset: FrameAsset,
|
|
30756
31898
|
// DECON-supplied: true when this scene is a length-split CONTINUATION of the
|
|
30757
31899
|
// previous one (the SAME physical shot, broken up only because it exceeded the
|
|
30758
31900
|
// clip ceiling). The scaffold then shares the splice keyframe — this scene's
|
|
30759
31901
|
// start frame IS the previous scene's end frame — so the join is seamless.
|
|
30760
|
-
continues_previous:
|
|
31902
|
+
continues_previous: z32.boolean().optional()
|
|
30761
31903
|
}).loose();
|
|
30762
|
-
var VideoBlueprint =
|
|
30763
|
-
source:
|
|
30764
|
-
global:
|
|
30765
|
-
music:
|
|
30766
|
-
present:
|
|
30767
|
-
music_prompt:
|
|
31904
|
+
var VideoBlueprint = z32.object({
|
|
31905
|
+
source: z32.object({ aspect_ratio: z32.string().optional(), duration_s: z32.number().optional() }).loose().optional(),
|
|
31906
|
+
global: z32.object({
|
|
31907
|
+
music: z32.object({
|
|
31908
|
+
present: z32.boolean().optional(),
|
|
31909
|
+
music_prompt: z32.string().optional(),
|
|
30768
31910
|
// Absolute second the music enters in the reference (the bed often
|
|
30769
31911
|
// kicks in mid-ad, after the hook). We start the regenerated track here
|
|
30770
31912
|
// instead of at 0 so the timing matches.
|
|
30771
|
-
starts_at_s:
|
|
31913
|
+
starts_at_s: z32.number().optional(),
|
|
30772
31914
|
// Populated by the deconstruct when AudD (Shazam-style) recognizes the
|
|
30773
31915
|
// reference track. We never reuse it — only style the regenerated bed.
|
|
30774
|
-
identified_track:
|
|
31916
|
+
identified_track: z32.object({ title: z32.string().optional(), artist: z32.string().optional() }).loose().nullish()
|
|
30775
31917
|
}).loose().optional(),
|
|
30776
|
-
cast:
|
|
30777
|
-
|
|
30778
|
-
id:
|
|
30779
|
-
description:
|
|
31918
|
+
cast: z32.array(
|
|
31919
|
+
z32.object({
|
|
31920
|
+
id: z32.string().optional(),
|
|
31921
|
+
description: z32.string().optional(),
|
|
30780
31922
|
// The deconstruct's note on the target-market localization (e.g. "native
|
|
30781
31923
|
// French speaker") — read to derive the spoken-track language code.
|
|
30782
|
-
market_localization_note:
|
|
31924
|
+
market_localization_note: z32.string().optional()
|
|
30783
31925
|
}).loose()
|
|
30784
31926
|
).optional(),
|
|
30785
|
-
voiceover:
|
|
31927
|
+
voiceover: z32.object({
|
|
30786
31928
|
// on_camera | mixed → mouths are on screen (lip-sync candidates);
|
|
30787
31929
|
// voiceover | none → narration over the picture (no lip-sync).
|
|
30788
|
-
mode:
|
|
30789
|
-
voice_description:
|
|
30790
|
-
persona:
|
|
31930
|
+
mode: z32.string().optional(),
|
|
31931
|
+
voice_description: z32.string().optional(),
|
|
31932
|
+
persona: z32.string().optional()
|
|
30791
31933
|
}).loose().optional(),
|
|
30792
31934
|
// Visual palette — read only to colour a clean brand-card/CTA plate (the
|
|
30793
31935
|
// first hex is the dominant brand colour); never to drive frame generation.
|
|
30794
|
-
style:
|
|
31936
|
+
style: z32.object({ palette: z32.array(z32.object({ hex: z32.string().optional() }).loose()).optional() }).loose().optional()
|
|
30795
31937
|
}).loose().optional(),
|
|
30796
|
-
scenes:
|
|
31938
|
+
scenes: z32.array(Scene).min(1)
|
|
30797
31939
|
}).loose();
|
|
30798
31940
|
function injectHookPhysicality(blueprint) {
|
|
30799
31941
|
for (const scene of blueprint.scenes) {
|
|
@@ -30810,26 +31952,26 @@ function clipIntentOf(scene, sceneIndex) {
|
|
|
30810
31952
|
if (/hero|reveal|product|payoff|transformation|result/.test(role) || scene.motion_level === "dynamic") return "hero";
|
|
30811
31953
|
return "body";
|
|
30812
31954
|
}
|
|
30813
|
-
var AppearsItem =
|
|
30814
|
-
var RecurringElement =
|
|
31955
|
+
var AppearsItem = z32.union([z32.number(), z32.object({ scene: z32.number(), edge: z32.string().optional() }).loose()]);
|
|
31956
|
+
var RecurringElement = z32.object({
|
|
30815
31957
|
// person | animal | product | logo | badge | other
|
|
30816
|
-
type:
|
|
30817
|
-
label:
|
|
30818
|
-
description:
|
|
30819
|
-
expression:
|
|
31958
|
+
type: z32.string(),
|
|
31959
|
+
label: z32.string().optional(),
|
|
31960
|
+
description: z32.string().optional(),
|
|
31961
|
+
expression: z32.string().nullable().optional(),
|
|
30820
31962
|
// When the element maps to a global cast entry, its stable id (for annotation).
|
|
30821
|
-
cast_id:
|
|
31963
|
+
cast_id: z32.string().nullable().optional(),
|
|
30822
31964
|
// The label of another element that is the SAME individual as this one, shown
|
|
30823
31965
|
// in a DIFFERENT wardrobe/persona/state (e.g. one creator playing skeptic in a
|
|
30824
31966
|
// pink shirt and believer in a white shirt). Each look gets its own reference
|
|
30825
31967
|
// slot, but the face/identity must stay identical across them.
|
|
30826
|
-
same_as:
|
|
31968
|
+
same_as: z32.string().nullable().optional(),
|
|
30827
31969
|
// Scenes the element appears in. Either a bare list of scene indices (both
|
|
30828
31970
|
// edges) or per-{scene,edge} entries. Both forms are accepted and merged.
|
|
30829
|
-
scenes:
|
|
30830
|
-
appears_in:
|
|
31971
|
+
scenes: z32.array(z32.number()).optional(),
|
|
31972
|
+
appears_in: z32.array(AppearsItem).optional()
|
|
30831
31973
|
}).loose();
|
|
30832
|
-
var RecurringElements =
|
|
31974
|
+
var RecurringElements = z32.array(RecurringElement);
|
|
30833
31975
|
function sanitizeId(raw, fallback) {
|
|
30834
31976
|
const id = raw.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
30835
31977
|
return /^[a-z]/.test(id) ? id : `${fallback}_${id}`.replace(/_+$/g, "") || fallback;
|
|
@@ -31308,7 +32450,7 @@ function scrubFloatSentences(text2, floatDescs) {
|
|
|
31308
32450
|
return kept;
|
|
31309
32451
|
}
|
|
31310
32452
|
function sceneFloatDescs(scene) {
|
|
31311
|
-
const floats =
|
|
32453
|
+
const floats = z32.array(FloatingElement).safeParse(scene.floating_elements ?? []);
|
|
31312
32454
|
if (!floats.success) return [];
|
|
31313
32455
|
return floats.data.map((f) => f.description?.trim() ?? "").filter(Boolean);
|
|
31314
32456
|
}
|
|
@@ -32741,25 +33883,25 @@ function buildSfxMusic(blueprint, clock, nodes) {
|
|
|
32741
33883
|
}
|
|
32742
33884
|
return tracks;
|
|
32743
33885
|
}
|
|
32744
|
-
var OverlayStyle =
|
|
32745
|
-
var Overlay =
|
|
32746
|
-
text:
|
|
32747
|
-
appears_at_s:
|
|
32748
|
-
duration_s:
|
|
32749
|
-
position:
|
|
32750
|
-
role:
|
|
32751
|
-
animation:
|
|
32752
|
-
animation_detail:
|
|
33886
|
+
var OverlayStyle = z32.object({ color_hex: z32.string().optional(), background: z32.string().optional(), size: z32.string().optional() }).loose();
|
|
33887
|
+
var Overlay = z32.object({
|
|
33888
|
+
text: z32.string().optional(),
|
|
33889
|
+
appears_at_s: z32.number().optional(),
|
|
33890
|
+
duration_s: z32.number().optional(),
|
|
33891
|
+
position: z32.string().optional(),
|
|
33892
|
+
role: z32.string().optional(),
|
|
33893
|
+
animation: z32.string().optional(),
|
|
33894
|
+
animation_detail: z32.string().optional(),
|
|
32753
33895
|
style: OverlayStyle.optional()
|
|
32754
33896
|
}).loose();
|
|
32755
|
-
var FloatingElement =
|
|
32756
|
-
kind:
|
|
32757
|
-
description:
|
|
32758
|
-
brand_name:
|
|
32759
|
-
what_it_represents:
|
|
32760
|
-
appears_at_s:
|
|
32761
|
-
duration_s:
|
|
32762
|
-
position:
|
|
33897
|
+
var FloatingElement = z32.object({
|
|
33898
|
+
kind: z32.string().optional(),
|
|
33899
|
+
description: z32.string().optional(),
|
|
33900
|
+
brand_name: z32.string().nullish(),
|
|
33901
|
+
what_it_represents: z32.string().optional(),
|
|
33902
|
+
appears_at_s: z32.number().optional(),
|
|
33903
|
+
duration_s: z32.number().optional(),
|
|
33904
|
+
position: z32.string().optional()
|
|
32763
33905
|
}).loose();
|
|
32764
33906
|
function escapeHtml(s) {
|
|
32765
33907
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -32791,7 +33933,7 @@ function positionClass(position) {
|
|
|
32791
33933
|
function collectCaptions(blueprint, clock) {
|
|
32792
33934
|
return blueprint.scenes.flatMap((scene, i) => {
|
|
32793
33935
|
const sceneStart = scene.start_s ?? 0;
|
|
32794
|
-
const overlays =
|
|
33936
|
+
const overlays = z32.array(Overlay).safeParse(scene.overlays ?? []);
|
|
32795
33937
|
return overlays.success ? overlays.data.filter((ov) => Boolean(ov.text?.trim())).map((ov) => {
|
|
32796
33938
|
const at = clock.map(i, ov.appears_at_s ?? sceneStart);
|
|
32797
33939
|
return { text: ov.text.trim(), at, end: at + (ov.duration_s ?? 2.5), ov };
|
|
@@ -32871,7 +34013,7 @@ function collectFloatWindows(blueprint, uiRouted, clock) {
|
|
|
32871
34013
|
const windows = /* @__PURE__ */ new Map();
|
|
32872
34014
|
blueprint.scenes.forEach((scene, i) => {
|
|
32873
34015
|
const sceneStart = scene.start_s ?? 0;
|
|
32874
|
-
const floats =
|
|
34016
|
+
const floats = z32.array(FloatingElement).safeParse(scene.floating_elements ?? []);
|
|
32875
34017
|
if (!floats.success) return;
|
|
32876
34018
|
for (const fe of floats.data) {
|
|
32877
34019
|
const at = clock.map(i, fe.appears_at_s ?? sceneStart);
|
|
@@ -33319,8 +34461,8 @@ function buildMotionBoard(blueprint) {
|
|
|
33319
34461
|
const end_s = scene.end_s ?? start_s + sceneDurationS(scene);
|
|
33320
34462
|
cursor = end_s;
|
|
33321
34463
|
const spoken = sceneSpokenText(scene);
|
|
33322
|
-
const overlays =
|
|
33323
|
-
const floats =
|
|
34464
|
+
const overlays = z32.array(Overlay).safeParse(scene.overlays ?? []);
|
|
34465
|
+
const floats = z32.array(FloatingElement).safeParse(scene.floating_elements ?? []);
|
|
33324
34466
|
const graphics = [
|
|
33325
34467
|
...(overlays.success ? overlays.data : []).filter((ov) => ov.text?.trim()).map((ov) => ({
|
|
33326
34468
|
kind: "text",
|
|
@@ -34780,7 +35922,7 @@ import path20 from "path";
|
|
|
34780
35922
|
import { defineCommand as defineCommand110 } from "citty";
|
|
34781
35923
|
|
|
34782
35924
|
// src/engine/scaffold/staticAd.ts
|
|
34783
|
-
import { z as
|
|
35925
|
+
import { z as z33 } from "zod";
|
|
34784
35926
|
var GEN_ASPECT_RATIOS = /* @__PURE__ */ new Set(["1:1", "4:5", "9:16", "16:9", "4:3", "3:4", "2:3", "3:2", "21:9"]);
|
|
34785
35927
|
var DEFAULT_ASPECT_RATIO = "9:16";
|
|
34786
35928
|
var SHEET_SUBJECT_TYPE2 = {
|
|
@@ -34792,24 +35934,24 @@ var ACTOR_SHEET_IMAGE_SIZE = "4K";
|
|
|
34792
35934
|
var ADAPT_MODEL = "google/gemini-3-pro-image-preview";
|
|
34793
35935
|
var ADAPT_IMAGE_SIZE = "2K";
|
|
34794
35936
|
var ADAPT_GUIDANCE = "Keep the headline, logo, CTA, and hero subject fully visible in every ratio. Reproduce every text string verbatim \u2014 no dropped, added, or altered characters \u2014 and preserve the exact brand-color treatment (e.g. a black\u2192red word pivot), never flattening it.";
|
|
34795
|
-
var Blueprint =
|
|
34796
|
-
meta:
|
|
34797
|
-
text_content:
|
|
35937
|
+
var Blueprint = z33.object({
|
|
35938
|
+
meta: z33.object({ estimated_aspect_ratio: z33.string().optional() }).loose().optional(),
|
|
35939
|
+
text_content: z33.array(z33.object({ text: z33.string().optional() }).loose()).optional()
|
|
34798
35940
|
}).loose();
|
|
34799
|
-
var ElementLocator =
|
|
34800
|
-
collection:
|
|
34801
|
-
index:
|
|
35941
|
+
var ElementLocator = z33.object({
|
|
35942
|
+
collection: z33.enum(["subjects", "people", "brands_logos"]),
|
|
35943
|
+
index: z33.number().int().nonnegative()
|
|
34802
35944
|
}).loose();
|
|
34803
|
-
var MainElement =
|
|
35945
|
+
var MainElement = z33.object({
|
|
34804
35946
|
// logo | product | person | animal | badge | other
|
|
34805
|
-
type:
|
|
34806
|
-
label:
|
|
34807
|
-
description:
|
|
34808
|
-
expression:
|
|
34809
|
-
reason:
|
|
35947
|
+
type: z33.string(),
|
|
35948
|
+
label: z33.string().optional(),
|
|
35949
|
+
description: z33.string().optional(),
|
|
35950
|
+
expression: z33.string().nullable().optional(),
|
|
35951
|
+
reason: z33.string().optional(),
|
|
34810
35952
|
locator: ElementLocator.optional()
|
|
34811
35953
|
}).loose();
|
|
34812
|
-
var MainElements =
|
|
35954
|
+
var MainElements = z33.array(MainElement);
|
|
34813
35955
|
function sanitizeId2(raw, fallback) {
|
|
34814
35956
|
const id = raw.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
34815
35957
|
return /^[a-z]/.test(id) ? id : `${fallback}_${id}`.replace(/_+$/g, "") || fallback;
|
|
@@ -35746,8 +36888,8 @@ function parsePySceneDetectCsvCuts(csv) {
|
|
|
35746
36888
|
const firstLine = csv.split(/\r?\n/, 1)[0] ?? "";
|
|
35747
36889
|
if (!/^\s*Timecode List:/i.test(firstLine)) return [];
|
|
35748
36890
|
const cuts = [];
|
|
35749
|
-
for (const
|
|
35750
|
-
const t = timecodeToSeconds(
|
|
36891
|
+
for (const cell3 of firstLine.split(",").slice(1)) {
|
|
36892
|
+
const t = timecodeToSeconds(cell3);
|
|
35751
36893
|
if (t !== null && t > 0) cuts.push(Math.round(t * 1e3) / 1e3);
|
|
35752
36894
|
}
|
|
35753
36895
|
return [...new Set(cuts)].sort((a, b) => a - b);
|