@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.
Files changed (3) hide show
  1. package/dist/cli.js +1287 -145
  2. package/dist/cli.js.map +1 -1
  3. 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
- /** Escape hatch for a page-specific event. */
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
- valueTotal: z27.number(),
13688
- landings: z27.array(z27.string())
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
- valueTotal: z27.number()
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((cell2) => cell2.trim());
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 buildAnalyticsHints(data, platform) {
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
- if (data.totals.pageViews > 0 && data.totals.sessions === 0) {
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 cell(value, key) {
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 table(rows) {
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) => cell(row[col], col)).join(" | ")} |`);
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}`, table(parents)];
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}`, table(value));
27294
- else if (isPlainRow(value)) parts.push(`#### ${name}[${labelOf(row, index)}].${key}`, table([value]));
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
- ${table(value)}`;
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" : cell(v, k)}`).join("\n")}`;
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
- ${cell(value)}`;
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 z30 } from "zod";
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 = z30.object({ url: z30.string().optional() }).loose().optional();
30650
- var DialogueLine = z30.object({
30651
- speaker: z30.string().optional(),
30652
- line: z30.string().optional(),
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: z30.number().optional(),
30655
- end_s: z30.number().optional(),
30656
- delivery: z30.string().optional(),
30657
- voice_description: z30.string().optional(),
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: z30.boolean().optional()
31805
+ on_camera: z32.boolean().optional()
30664
31806
  }).loose();
30665
- var Sfx = z30.object({
30666
- at_s: z30.number().optional(),
30667
- duration_s: z30.number().optional(),
30668
- sound_effect_prompt: z30.string().optional(),
30669
- description: z30.string().optional()
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 = z30.object({
31813
+ var CompositionRegion = z32.object({
30672
31814
  // full | top | bottom | left | right | inset
30673
- panel: z30.string().optional(),
31815
+ panel: z32.string().optional(),
30674
31816
  // 9-grid anchor for an `inset` presenter box.
30675
- position: z30.string().optional(),
30676
- is_presenter: z30.boolean().optional(),
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: z30.string().optional(),
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: z30.string().optional(),
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: z30.string().optional(),
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: z30.array(z30.object({}).loose()).optional(),
30695
- summary: z30.string().optional(),
30696
- frame_prompt: z30.string().optional(),
30697
- motion_prompt: z30.string().optional()
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 = z30.object({
31841
+ var SceneComposition = z32.object({
30700
31842
  // full_frame (default) | split_screen | pip | keyed_overlay
30701
- layout: z30.string().optional(),
31843
+ layout: z32.string().optional(),
30702
31844
  // split_screen only: vertical (top/bottom) | horizontal (left/right).
30703
- split_axis: z30.string().optional(),
30704
- regions: z30.array(CompositionRegion).optional()
31845
+ split_axis: z32.string().optional(),
31846
+ regions: z32.array(CompositionRegion).optional()
30705
31847
  }).loose();
30706
- var CameraMotion = z30.object({ movement: z30.string().optional(), detail: z30.string().optional() }).loose();
30707
- var TranscriptWord = z30.object({ text: z30.string().optional() }).loose();
30708
- var Scene = z30.object({
30709
- start_s: z30.number().optional(),
30710
- end_s: z30.number().optional(),
30711
- duration_s: z30.number().optional(),
30712
- summary: z30.string().optional(),
30713
- action_detail: z30.string().optional(),
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: z30.string().optional(),
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: z30.string().optional(),
31866
+ ambient: z32.string().optional(),
30725
31867
  camera_motion: CameraMotion.optional(),
30726
- start_frame_prompt: z30.string().optional(),
30727
- end_frame_prompt: z30.string().optional(),
30728
- motion_prompt: z30.string().optional(),
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: z30.string().optional(),
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: z30.object({ mechanic: z30.string().optional(), why_it_stops_scroll: z30.string().optional() }).loose().optional(),
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: z30.string().optional(),
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: z30.object({ type: z30.string().optional(), description: z30.string().optional() }).loose().optional(),
30744
- dialogue: z30.array(DialogueLine).optional(),
30745
- sfx: z30.array(Sfx).optional(),
30746
- overlays: z30.array(z30.unknown()).optional(),
30747
- floating_elements: z30.array(z30.unknown()).optional(),
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: z30.enum(["static", "subtle", "dynamic"]).optional(),
30753
- transcript_slice: z30.array(TranscriptWord).optional(),
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: z30.boolean().optional()
31902
+ continues_previous: z32.boolean().optional()
30761
31903
  }).loose();
30762
- var VideoBlueprint = z30.object({
30763
- source: z30.object({ aspect_ratio: z30.string().optional(), duration_s: z30.number().optional() }).loose().optional(),
30764
- global: z30.object({
30765
- music: z30.object({
30766
- present: z30.boolean().optional(),
30767
- music_prompt: z30.string().optional(),
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: z30.number().optional(),
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: z30.object({ title: z30.string().optional(), artist: z30.string().optional() }).loose().nullish()
31916
+ identified_track: z32.object({ title: z32.string().optional(), artist: z32.string().optional() }).loose().nullish()
30775
31917
  }).loose().optional(),
30776
- cast: z30.array(
30777
- z30.object({
30778
- id: z30.string().optional(),
30779
- description: z30.string().optional(),
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: z30.string().optional()
31924
+ market_localization_note: z32.string().optional()
30783
31925
  }).loose()
30784
31926
  ).optional(),
30785
- voiceover: z30.object({
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: z30.string().optional(),
30789
- voice_description: z30.string().optional(),
30790
- persona: z30.string().optional()
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: z30.object({ palette: z30.array(z30.object({ hex: z30.string().optional() }).loose()).optional() }).loose().optional()
31936
+ style: z32.object({ palette: z32.array(z32.object({ hex: z32.string().optional() }).loose()).optional() }).loose().optional()
30795
31937
  }).loose().optional(),
30796
- scenes: z30.array(Scene).min(1)
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 = z30.union([z30.number(), z30.object({ scene: z30.number(), edge: z30.string().optional() }).loose()]);
30814
- var RecurringElement = z30.object({
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: z30.string(),
30817
- label: z30.string().optional(),
30818
- description: z30.string().optional(),
30819
- expression: z30.string().nullable().optional(),
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: z30.string().nullable().optional(),
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: z30.string().nullable().optional(),
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: z30.array(z30.number()).optional(),
30830
- appears_in: z30.array(AppearsItem).optional()
31971
+ scenes: z32.array(z32.number()).optional(),
31972
+ appears_in: z32.array(AppearsItem).optional()
30831
31973
  }).loose();
30832
- var RecurringElements = z30.array(RecurringElement);
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 = z30.array(FloatingElement).safeParse(scene.floating_elements ?? []);
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 = z30.object({ color_hex: z30.string().optional(), background: z30.string().optional(), size: z30.string().optional() }).loose();
32745
- var Overlay = z30.object({
32746
- text: z30.string().optional(),
32747
- appears_at_s: z30.number().optional(),
32748
- duration_s: z30.number().optional(),
32749
- position: z30.string().optional(),
32750
- role: z30.string().optional(),
32751
- animation: z30.string().optional(),
32752
- animation_detail: z30.string().optional(),
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 = z30.object({
32756
- kind: z30.string().optional(),
32757
- description: z30.string().optional(),
32758
- brand_name: z30.string().nullish(),
32759
- what_it_represents: z30.string().optional(),
32760
- appears_at_s: z30.number().optional(),
32761
- duration_s: z30.number().optional(),
32762
- position: z30.string().optional()
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -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 = z30.array(Overlay).safeParse(scene.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 = z30.array(FloatingElement).safeParse(scene.floating_elements ?? []);
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 = z30.array(Overlay).safeParse(scene.overlays ?? []);
33323
- const floats = z30.array(FloatingElement).safeParse(scene.floating_elements ?? []);
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 z31 } from "zod";
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 = z31.object({
34796
- meta: z31.object({ estimated_aspect_ratio: z31.string().optional() }).loose().optional(),
34797
- text_content: z31.array(z31.object({ text: z31.string().optional() }).loose()).optional()
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 = z31.object({
34800
- collection: z31.enum(["subjects", "people", "brands_logos"]),
34801
- index: z31.number().int().nonnegative()
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 = z31.object({
35945
+ var MainElement = z33.object({
34804
35946
  // logo | product | person | animal | badge | other
34805
- type: z31.string(),
34806
- label: z31.string().optional(),
34807
- description: z31.string().optional(),
34808
- expression: z31.string().nullable().optional(),
34809
- reason: z31.string().optional(),
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 = z31.array(MainElement);
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 cell2 of firstLine.split(",").slice(1)) {
35750
- const t = timecodeToSeconds(cell2);
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);