@blamejs/blamejs-shop 0.0.66 → 0.0.72

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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +36 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/loyalty-earn-rules.js +786 -0
  19. package/lib/operator-roles.js +768 -0
  20. package/lib/order-escalation.js +951 -0
  21. package/lib/order-ratings.js +495 -0
  22. package/lib/order-tags.js +944 -0
  23. package/lib/packing-slips.js +810 -0
  24. package/lib/pixel-events.js +995 -0
  25. package/lib/print-queue.js +681 -0
  26. package/lib/product-qa.js +749 -0
  27. package/lib/promo-bundles.js +835 -0
  28. package/lib/push-notifications.js +937 -0
  29. package/lib/refund-automation.js +853 -0
  30. package/lib/reorder-reminders.js +798 -0
  31. package/lib/robots-config.js +753 -0
  32. package/lib/seller-signup.js +1052 -0
  33. package/lib/sitemap-generator.js +717 -0
  34. package/lib/split-shipments.js +7 -1
  35. package/lib/subscription-gifts.js +710 -0
  36. package/lib/tax-cert-renewals.js +632 -0
  37. package/lib/tier-benefits.js +776 -0
  38. package/lib/vendor/MANIFEST.json +2 -2
  39. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  40. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  41. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  42. package/lib/vendor/blamejs/package.json +1 -1
  43. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  44. package/lib/wishlist-alerts.js +842 -0
  45. package/lib/wishlist-sharing.js +718 -0
  46. package/package.json +1 -1
@@ -0,0 +1,1077 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.localeRouter
4
+ * @title Storefront locale routing — pick the right BCP-47 tag for
5
+ * an incoming request from URL prefix / subdomain / cookie /
6
+ * Accept-Language / customer preference.
7
+ *
8
+ * @intro
9
+ * The shop renders one storefront in many languages. An incoming
10
+ * request to the homepage of a multi-locale storefront answers a
11
+ * single question first: *which locale do I render this page in*.
12
+ * This primitive owns that question. The operator declares
13
+ *
14
+ * - a catalog of locales (`defineLocale`) — every BCP-47 tag the
15
+ * storefront knows about, classified `primary` / `regional` /
16
+ * `variant`, with an optional `fallback` chain for when a
17
+ * picked locale isn't supported by the active policy and a
18
+ * `currency` default that downstream display primitives use
19
+ * to pre-fill the buyer's currency selector;
20
+ *
21
+ * - a routing policy (`definePolicy`) — strategy + default
22
+ * locale + supported list. A storefront can carry multiple
23
+ * policies (marketing site, app, help center) but at most one
24
+ * is active at a time. `setActivePolicy(slug)` flips the
25
+ * active one in place; the partial-unique index in migration
26
+ * 0139 enforces the at-most-one invariant at the SQL tier.
27
+ *
28
+ * `resolveLocale({ request })` walks the request hints in a fixed
29
+ * precedence (which subset applies depends on the active policy's
30
+ * strategy):
31
+ *
32
+ * 1. `customer_id` -> customer_locale_prefs (when strategy ==
33
+ * `customer_preference`, OR for every strategy when the row
34
+ * exists — operators expect a logged-in buyer's explicit pick
35
+ * to survive across surface changes);
36
+ * 2. URL prefix (`/en/...`, `/de/...`, `/fr-ca/...`) — strategy
37
+ * `url_prefix`;
38
+ * 3. Subdomain (`de.example.com`) — strategy `subdomain`;
39
+ * 4. Cookie (`cookie_locale`) — strategy `cookie`;
40
+ * 5. Accept-Language q-sorted list — every strategy; the last
41
+ * hint before the default.
42
+ * 6. The policy's `default_locale`.
43
+ *
44
+ * Each step returns its hit only when the candidate tag is in the
45
+ * policy's `supported_locales` list OR when the tag's `fallback`
46
+ * chain (defined on the locale catalog) resolves to one that is.
47
+ * A request whose final resolution is the default locale records
48
+ * `source: "default"` even when an upstream hint was present —
49
+ * operators reconciling "why did this buyer see English?" should
50
+ * see the truthful "we walked all hints and none matched."
51
+ *
52
+ * The return shape is
53
+ *
54
+ * { locale: string, source: string, canonical_url?: string }
55
+ *
56
+ * The `canonical_url` is emitted only for `url_prefix` and
57
+ * `subdomain` strategies — it's the canonical form of the
58
+ * request URL with the resolved locale baked in (operators stamp
59
+ * it into a `<link rel="canonical">` to coalesce duplicate
60
+ * indexing on the SEO side).
61
+ *
62
+ * Composition:
63
+ *
64
+ * var lr = localeRouter.create({
65
+ * query: q,
66
+ * customers: bShop.customers, // optional — never required
67
+ * geolocation: bShop.geolocation,// optional, accepted for
68
+ * // composition symmetry; the
69
+ * // primitive resolves country
70
+ * // hints only as Accept-Language
71
+ * // fallbacks.
72
+ * });
73
+ * await lr.defineLocale({ tag: "en", kind: "primary", currency: "USD", active: true });
74
+ * await lr.defineLocale({ tag: "en-US", kind: "regional", fallback: "en", currency: "USD", active: true });
75
+ * await lr.defineLocale({ tag: "de", kind: "primary", currency: "EUR", active: true });
76
+ * await lr.definePolicy({
77
+ * slug: "main",
78
+ * strategy: "url_prefix",
79
+ * default_locale: "en",
80
+ * supported_locales: ["en", "en-US", "de"],
81
+ * });
82
+ * await lr.setActivePolicy("main");
83
+ * var out = await lr.resolveLocale({
84
+ * request: {
85
+ * host: "example.com",
86
+ * path: "/de/about",
87
+ * accept_language: "de-DE,de;q=0.9,en;q=0.5",
88
+ * },
89
+ * });
90
+ * // { locale: "de", source: "url_prefix",
91
+ * // canonical_url: "https://example.com/de/about" }
92
+ *
93
+ * Storage:
94
+ * - `locales_defined` + `locale_policies` +
95
+ * `customer_locale_prefs` + `locale_resolutions_log`
96
+ * (migration `0139_locale_router.sql`).
97
+ *
98
+ * @primitive localeRouter
99
+ * @related shop.geolocation, shop.priceDisplay, shop.currencyDisplay
100
+ */
101
+
102
+ var bShop;
103
+ function _b() {
104
+ if (!bShop) bShop = require("./index");
105
+ return bShop.framework;
106
+ }
107
+
108
+ // ---- constants ----------------------------------------------------------
109
+
110
+ var KINDS = ["primary", "regional", "variant"];
111
+ var STRATEGIES = [
112
+ "url_prefix",
113
+ "subdomain",
114
+ "cookie",
115
+ "accept_language_only",
116
+ "customer_preference",
117
+ ];
118
+
119
+ // BCP-47 — primary language subtag (2-3 letters) plus optional
120
+ // region / script / variant subtags joined by `-`. Practical
121
+ // storefront locales fit the `lang(-Region)?` envelope; the schema
122
+ // CHECK bounds total length at 35 (IETF practical max).
123
+ var LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/;
124
+ var LOCALE_MAX = 35;
125
+ // ISO 4217 — three uppercase letters.
126
+ var CURRENCY_RE = /^[A-Z]{3}$/;
127
+ // Slug for policy primary key — lowercase alphanumerics + dash +
128
+ // underscore, 1-80 chars.
129
+ var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/;
130
+ // customer_id — opaque, but bounded so we don't store a megabyte by
131
+ // accident.
132
+ var CUSTOMER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
133
+ // Host — RFC 1123 subset; we accept lowercase / digit / dot / dash,
134
+ // 1-253 chars. Port is rejected (the caller strips it before
135
+ // passing in).
136
+ var HOST_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
137
+ var HOST_MAX = 253;
138
+ // Path — `/`-prefixed, no control bytes, bounded length so we don't
139
+ // log a megabyte from a malicious caller.
140
+ var PATH_MAX = 2048;
141
+ // Accept-Language and cookie values are bounded so a buggy upstream
142
+ // can't OOM us through the log.
143
+ var HEADER_MAX = 4096;
144
+ var SUPPORTED_MIN = 1;
145
+ var SUPPORTED_MAX = 128;
146
+ var MAX_LIST_LIMIT = 500;
147
+ // Fallback walking — cap the chain depth so a manually-poked
148
+ // `fallback = self` loop bounces off at a deterministic point.
149
+ var FALLBACK_MAX_DEPTH = 16;
150
+
151
+ // ---- validators ---------------------------------------------------------
152
+
153
+ function _locale(s, label) {
154
+ if (typeof s !== "string" || !LOCALE_RE.test(s) || s.length > LOCALE_MAX) {
155
+ throw new TypeError(
156
+ "localeRouter: " + label + " must be a BCP-47 language tag " +
157
+ "(e.g. 'en', 'de-DE'), got " + JSON.stringify(s)
158
+ );
159
+ }
160
+ return s;
161
+ }
162
+
163
+ function _kind(s) {
164
+ if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
165
+ throw new TypeError(
166
+ "localeRouter: kind must be one of " + KINDS.join(", ") +
167
+ ", got " + JSON.stringify(s)
168
+ );
169
+ }
170
+ return s;
171
+ }
172
+
173
+ function _strategy(s) {
174
+ if (typeof s !== "string" || STRATEGIES.indexOf(s) === -1) {
175
+ throw new TypeError(
176
+ "localeRouter: strategy must be one of " + STRATEGIES.join(", ") +
177
+ ", got " + JSON.stringify(s)
178
+ );
179
+ }
180
+ return s;
181
+ }
182
+
183
+ function _currency(s) {
184
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
185
+ throw new TypeError(
186
+ "localeRouter: currency must be a 3-letter uppercase ISO 4217 code, got " +
187
+ JSON.stringify(s)
188
+ );
189
+ }
190
+ return s;
191
+ }
192
+
193
+ function _slug(s) {
194
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
195
+ throw new TypeError(
196
+ "localeRouter: slug must match /^[a-z0-9][a-z0-9_-]{0,79}$/, got " +
197
+ JSON.stringify(s)
198
+ );
199
+ }
200
+ return s;
201
+ }
202
+
203
+ function _customerId(s, label) {
204
+ label = label || "customer_id";
205
+ if (typeof s !== "string" || !CUSTOMER_ID_RE.test(s)) {
206
+ throw new TypeError(
207
+ "localeRouter: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/, got " +
208
+ JSON.stringify(s)
209
+ );
210
+ }
211
+ return s;
212
+ }
213
+
214
+ function _bool(v, label) {
215
+ if (typeof v !== "boolean") {
216
+ throw new TypeError("localeRouter: " + label + " must be a boolean");
217
+ }
218
+ return v;
219
+ }
220
+
221
+ function _supportedList(arr, label) {
222
+ if (!Array.isArray(arr)) {
223
+ throw new TypeError("localeRouter: " + label + " must be an array of locale tags");
224
+ }
225
+ if (arr.length < SUPPORTED_MIN || arr.length > SUPPORTED_MAX) {
226
+ throw new TypeError(
227
+ "localeRouter: " + label + " must contain between " + SUPPORTED_MIN +
228
+ " and " + SUPPORTED_MAX + " entries, got " + arr.length
229
+ );
230
+ }
231
+ var seen = Object.create(null);
232
+ var out = [];
233
+ for (var i = 0; i < arr.length; i += 1) {
234
+ var t = _locale(arr[i], label + "[" + i + "]");
235
+ if (seen[t]) {
236
+ throw new TypeError(
237
+ "localeRouter: " + label + " must not contain duplicate tag " +
238
+ JSON.stringify(t)
239
+ );
240
+ }
241
+ seen[t] = true;
242
+ out.push(t);
243
+ }
244
+ return out;
245
+ }
246
+
247
+ function _host(s, label) {
248
+ label = label || "host";
249
+ if (typeof s !== "string" || !s.length || s.length > HOST_MAX) {
250
+ throw new TypeError(
251
+ "localeRouter: " + label + " must be a non-empty string <= " +
252
+ HOST_MAX + " chars"
253
+ );
254
+ }
255
+ // Accept `Host:` style hostnames in either case but normalise to
256
+ // lowercase before matching; subdomain detection is case-insensitive.
257
+ var lower = s.toLowerCase();
258
+ // Strip an `:port` suffix defensively — operators sometimes feed in
259
+ // the raw `Host` header, which on a non-standard port carries the
260
+ // suffix.
261
+ var colon = lower.indexOf(":");
262
+ if (colon !== -1) lower = lower.slice(0, colon);
263
+ if (!HOST_RE.test(lower)) {
264
+ throw new TypeError(
265
+ "localeRouter: " + label + " must be a valid hostname, got " +
266
+ JSON.stringify(s)
267
+ );
268
+ }
269
+ return lower;
270
+ }
271
+
272
+ function _path(s, label) {
273
+ label = label || "path";
274
+ if (typeof s !== "string" || s.length === 0 || s.length > PATH_MAX) {
275
+ throw new TypeError(
276
+ "localeRouter: " + label + " must be a non-empty string <= " +
277
+ PATH_MAX + " chars"
278
+ );
279
+ }
280
+ if (s.charAt(0) !== "/") {
281
+ throw new TypeError(
282
+ "localeRouter: " + label + " must start with '/', got " +
283
+ JSON.stringify(s)
284
+ );
285
+ }
286
+ if (/[\x00-\x1F\x7F]/.test(s)) {
287
+ throw new TypeError("localeRouter: " + label + " must not contain control bytes");
288
+ }
289
+ return s;
290
+ }
291
+
292
+ function _headerOpt(s, label) {
293
+ if (s == null) return null;
294
+ if (typeof s !== "string") {
295
+ throw new TypeError("localeRouter: " + label + " must be a string or null");
296
+ }
297
+ if (s.length > HEADER_MAX) {
298
+ throw new TypeError(
299
+ "localeRouter: " + label + " must be <= " + HEADER_MAX + " chars"
300
+ );
301
+ }
302
+ if (/[\x00-\x1F\x7F]/.test(s)) {
303
+ throw new TypeError("localeRouter: " + label + " must not contain control bytes");
304
+ }
305
+ return s;
306
+ }
307
+
308
+ function _limit(n, label) {
309
+ if (n == null) return 100;
310
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_LIST_LIMIT) {
311
+ throw new TypeError(
312
+ "localeRouter: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]"
313
+ );
314
+ }
315
+ return n;
316
+ }
317
+
318
+ function _epochMs(ts, label) {
319
+ if (ts == null) return null;
320
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
321
+ throw new TypeError(
322
+ "localeRouter: " + label + " must be a non-negative integer epoch-ms, got " +
323
+ JSON.stringify(ts)
324
+ );
325
+ }
326
+ return ts;
327
+ }
328
+
329
+ // ---- accept-language parsing -------------------------------------------
330
+
331
+ // Same shape as the geolocation primitive — parse the RFC 7231
332
+ // header into a q-sorted list of `{ tag, q, order }` entries.
333
+ // Garbage entries silently drop; the worst case is an entirely
334
+ // malformed header that falls through to the next resolution step.
335
+ function _parseAcceptLanguage(raw) {
336
+ if (typeof raw !== "string" || !raw.length) return [];
337
+ var entries = raw.split(",");
338
+ var parsed = [];
339
+ for (var i = 0; i < entries.length; i += 1) {
340
+ var part = entries[i].trim();
341
+ if (!part) continue;
342
+ var semi = part.indexOf(";");
343
+ var tag = (semi === -1 ? part : part.slice(0, semi)).trim();
344
+ var q = 1.0;
345
+ if (semi !== -1) {
346
+ var attrs = part.slice(semi + 1).split(";");
347
+ for (var j = 0; j < attrs.length; j += 1) {
348
+ var a = attrs[j].trim();
349
+ if (a.slice(0, 2).toLowerCase() === "q=") {
350
+ var n = parseFloat(a.slice(2));
351
+ if (isFinite(n) && n >= 0 && n <= 1) q = n;
352
+ }
353
+ }
354
+ }
355
+ if (tag === "*") continue;
356
+ if (!LOCALE_RE.test(tag) || tag.length > LOCALE_MAX) continue;
357
+ parsed.push({ tag: tag, q: q, order: i });
358
+ }
359
+ parsed.sort(function (a, b) {
360
+ if (a.q !== b.q) return b.q - a.q;
361
+ return a.order - b.order;
362
+ });
363
+ return parsed;
364
+ }
365
+
366
+ // ---- url + subdomain extraction ----------------------------------------
367
+
368
+ // Pull the first path segment when it is shaped like a BCP-47 tag.
369
+ // Returns the lowercased tag (matching the storage convention used
370
+ // throughout — `defineLocale` preserves the operator's casing on the
371
+ // catalog row, but resolution comparisons fold both sides to lower
372
+ // case) or null when the path has no leading locale segment.
373
+ function _urlPrefixCandidate(path) {
374
+ var rest = path.slice(1); // drop leading "/"
375
+ if (!rest.length) return null;
376
+ var slash = rest.indexOf("/");
377
+ var seg = slash === -1 ? rest : rest.slice(0, slash);
378
+ if (!seg.length || seg.length > LOCALE_MAX) return null;
379
+ if (!LOCALE_RE.test(seg)) return null;
380
+ return seg;
381
+ }
382
+
383
+ // Pull the leftmost subdomain when it is shaped like a BCP-47 tag.
384
+ // `de.example.com` -> `de`. `www.example.com` -> null (the regex
385
+ // rejects 4+ letter primary tags on the assumption that no language
386
+ // tag is longer than 3 letters in its primary subtag — keeps `www`
387
+ // / `app` / `api` from being mistaken for locales).
388
+ function _subdomainCandidate(host) {
389
+ if (host.indexOf(".") === -1) return null;
390
+ var first = host.slice(0, host.indexOf("."));
391
+ if (!first.length || first.length > LOCALE_MAX) return null;
392
+ if (!LOCALE_RE.test(first)) return null;
393
+ return first;
394
+ }
395
+
396
+ // ---- row shaping -------------------------------------------------------
397
+
398
+ function _shapeLocale(row) {
399
+ if (!row) return null;
400
+ return {
401
+ tag: row.tag,
402
+ kind: row.kind,
403
+ fallback: row.fallback == null ? null : row.fallback,
404
+ currency: row.currency,
405
+ active: Number(row.active) === 1,
406
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
407
+ created_at: Number(row.created_at),
408
+ updated_at: Number(row.updated_at),
409
+ };
410
+ }
411
+
412
+ function _shapePolicy(row) {
413
+ if (!row) return null;
414
+ var supported;
415
+ try { supported = JSON.parse(row.supported_locales_json); }
416
+ catch (_e) {
417
+ throw new Error(
418
+ "localeRouter: supported_locales_json column is malformed JSON — storage corruption"
419
+ );
420
+ }
421
+ if (!Array.isArray(supported)) {
422
+ throw new Error(
423
+ "localeRouter: supported_locales_json must be a JSON array — storage corruption"
424
+ );
425
+ }
426
+ return {
427
+ slug: row.slug,
428
+ strategy: row.strategy,
429
+ default_locale: row.default_locale,
430
+ supported_locales: supported,
431
+ is_active: Number(row.is_active) === 1,
432
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
433
+ created_at: Number(row.created_at),
434
+ updated_at: Number(row.updated_at),
435
+ };
436
+ }
437
+
438
+ function _shapeCustomerPref(row) {
439
+ if (!row) return null;
440
+ return {
441
+ customer_id: row.customer_id,
442
+ locale: row.locale,
443
+ set_at: Number(row.set_at),
444
+ updated_at: Number(row.updated_at),
445
+ };
446
+ }
447
+
448
+ // ---- factory -----------------------------------------------------------
449
+
450
+ function create(opts) {
451
+ opts = opts || {};
452
+ var query = opts.query;
453
+ if (!query) {
454
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
455
+ }
456
+ if (typeof query !== "function") {
457
+ throw new TypeError("localeRouter.create: query must be a function");
458
+ }
459
+ // The `customers` and `geolocation` deps are accepted for
460
+ // composition symmetry — the primitive validates customer ids by
461
+ // shape (opaque string) and never round-trips through the customers
462
+ // primitive, and it consumes geolocation hints only via the
463
+ // request shape's existing fields. Both args are recorded so a
464
+ // future operator-level hook can wire them without breaking this
465
+ // signature.
466
+ /* eslint-disable no-unused-vars */
467
+ var customers = opts.customers || null;
468
+ var geolocation = opts.geolocation || null;
469
+ /* eslint-enable no-unused-vars */
470
+
471
+ // Monotonic clock — per-row class. The primitive guarantees a
472
+ // strictly-monotonic per-class updated_at sequence so a tied-
473
+ // millisecond write doesn't lose ordering against the prior write.
474
+ // Same shape as the currency-rounding / refund-policy clamps.
475
+ var lastTsLocale = Object.create(null);
476
+ var lastTsPolicy = Object.create(null);
477
+ var lastTsCustomer = Object.create(null);
478
+ // Resolutions log carries the wall-clock — monotonic per stream
479
+ // so a busy second still orders correctly.
480
+ var lastTsLog = 0;
481
+
482
+ function _now() { return Date.now(); }
483
+ function _clampLocale(tag, requested) {
484
+ var prior = lastTsLocale[tag];
485
+ var t = requested;
486
+ if (prior != null && t <= prior) t = prior + 1;
487
+ lastTsLocale[tag] = t;
488
+ return t;
489
+ }
490
+ function _clampPolicy(slug, requested) {
491
+ var prior = lastTsPolicy[slug];
492
+ var t = requested;
493
+ if (prior != null && t <= prior) t = prior + 1;
494
+ lastTsPolicy[slug] = t;
495
+ return t;
496
+ }
497
+ function _clampCustomer(cid, requested) {
498
+ var prior = lastTsCustomer[cid];
499
+ var t = requested;
500
+ if (prior != null && t <= prior) t = prior + 1;
501
+ lastTsCustomer[cid] = t;
502
+ return t;
503
+ }
504
+ function _clampLog(requested) {
505
+ var t = requested;
506
+ if (t <= lastTsLog) t = lastTsLog + 1;
507
+ lastTsLog = t;
508
+ return t;
509
+ }
510
+
511
+ // ---- locale catalog ------------------------------------------------
512
+
513
+ async function _readLocale(tag) {
514
+ var r = await query(
515
+ "SELECT tag, kind, fallback, currency, active, archived_at, " +
516
+ "created_at, updated_at FROM locales_defined WHERE tag = ?1 LIMIT 1",
517
+ [tag]
518
+ );
519
+ return r.rows[0] || null;
520
+ }
521
+
522
+ async function defineLocale(input) {
523
+ if (!input || typeof input !== "object") {
524
+ throw new TypeError("localeRouter.defineLocale: input object required");
525
+ }
526
+ var tag = _locale(input.tag, "tag");
527
+ var kind = _kind(input.kind);
528
+ var currency = _currency(input.currency);
529
+ var active = _bool(input.active, "active");
530
+ var fallback = input.fallback == null ? null : _locale(input.fallback, "fallback");
531
+
532
+ if (fallback != null) {
533
+ if (fallback === tag) {
534
+ throw new TypeError(
535
+ "localeRouter.defineLocale: fallback cannot equal tag (would loop)"
536
+ );
537
+ }
538
+ // Fallback must reference an existing locale row. The schema
539
+ // doesn't FK this (D1 best-effort), so we enforce it here.
540
+ var fbRow = await _readLocale(fallback);
541
+ if (!fbRow) {
542
+ throw new TypeError(
543
+ "localeRouter.defineLocale: fallback " + JSON.stringify(fallback) +
544
+ " does not reference a known locale"
545
+ );
546
+ }
547
+ }
548
+
549
+ var existing = await _readLocale(tag);
550
+ var ts = _clampLocale(tag, _now());
551
+ if (existing) {
552
+ // Re-define-after-archive (or in-place refresh) — re-set every
553
+ // operator-facing column. `archived_at` clears unless `active`
554
+ // is false.
555
+ await query(
556
+ "UPDATE locales_defined SET kind = ?1, fallback = ?2, currency = ?3, " +
557
+ "active = ?4, archived_at = ?5, updated_at = ?6 WHERE tag = ?7",
558
+ [
559
+ kind,
560
+ fallback,
561
+ currency,
562
+ active ? 1 : 0,
563
+ active ? null : ts,
564
+ ts,
565
+ tag,
566
+ ]
567
+ );
568
+ } else {
569
+ await query(
570
+ "INSERT INTO locales_defined " +
571
+ "(tag, kind, fallback, currency, active, archived_at, created_at, updated_at) " +
572
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
573
+ [
574
+ tag, kind, fallback, currency,
575
+ active ? 1 : 0,
576
+ active ? null : ts,
577
+ ts,
578
+ ]
579
+ );
580
+ }
581
+ return _shapeLocale(await _readLocale(tag));
582
+ }
583
+
584
+ async function getLocale(tag) {
585
+ return _shapeLocale(await _readLocale(_locale(tag, "tag")));
586
+ }
587
+
588
+ async function listLocales(input) {
589
+ input = input || {};
590
+ var activeOnly = input.active_only == null ? false : input.active_only;
591
+ if (typeof activeOnly !== "boolean") {
592
+ throw new TypeError("localeRouter.listLocales: active_only must be a boolean");
593
+ }
594
+ var limit = _limit(input.limit, "limit");
595
+ var sql = "SELECT tag, kind, fallback, currency, active, archived_at, " +
596
+ "created_at, updated_at FROM locales_defined";
597
+ var params = [];
598
+ if (activeOnly) sql += " WHERE active = 1";
599
+ sql += " ORDER BY tag ASC LIMIT ?" + (params.length + 1);
600
+ params.push(limit);
601
+ var r = await query(sql, params);
602
+ var out = [];
603
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_shapeLocale(r.rows[i]));
604
+ return out;
605
+ }
606
+
607
+ // ---- policy catalog ------------------------------------------------
608
+
609
+ async function _readPolicy(slug) {
610
+ var r = await query(
611
+ "SELECT slug, strategy, default_locale, supported_locales_json, " +
612
+ "is_active, archived_at, created_at, updated_at " +
613
+ "FROM locale_policies WHERE slug = ?1 LIMIT 1",
614
+ [slug]
615
+ );
616
+ return r.rows[0] || null;
617
+ }
618
+
619
+ async function definePolicy(input) {
620
+ if (!input || typeof input !== "object") {
621
+ throw new TypeError("localeRouter.definePolicy: input object required");
622
+ }
623
+ var slug = _slug(input.slug);
624
+ var strategy = _strategy(input.strategy);
625
+ var defaultLocale = _locale(input.default_locale, "default_locale");
626
+ var supportedLocales = _supportedList(input.supported_locales, "supported_locales");
627
+
628
+ if (supportedLocales.indexOf(defaultLocale) === -1) {
629
+ throw new TypeError(
630
+ "localeRouter.definePolicy: default_locale " + JSON.stringify(defaultLocale) +
631
+ " must appear in supported_locales"
632
+ );
633
+ }
634
+ // Every entry in supported_locales must reference an existing
635
+ // locale row. Walk the list in order so the error message points
636
+ // at the first offender deterministically.
637
+ for (var i = 0; i < supportedLocales.length; i += 1) {
638
+ var row = await _readLocale(supportedLocales[i]);
639
+ if (!row) {
640
+ throw new TypeError(
641
+ "localeRouter.definePolicy: supported_locales[" + i + "] " +
642
+ JSON.stringify(supportedLocales[i]) + " does not reference a known locale"
643
+ );
644
+ }
645
+ }
646
+
647
+ var existing = await _readPolicy(slug);
648
+ var ts = _clampPolicy(slug, _now());
649
+ var supportedJson = JSON.stringify(supportedLocales);
650
+ if (existing) {
651
+ // Re-define refreshes in place — but never silently flips
652
+ // is_active. Operators flip the active policy via
653
+ // setActivePolicy.
654
+ await query(
655
+ "UPDATE locale_policies SET strategy = ?1, default_locale = ?2, " +
656
+ "supported_locales_json = ?3, archived_at = NULL, updated_at = ?4 " +
657
+ "WHERE slug = ?5",
658
+ [strategy, defaultLocale, supportedJson, ts, slug]
659
+ );
660
+ } else {
661
+ await query(
662
+ "INSERT INTO locale_policies " +
663
+ "(slug, strategy, default_locale, supported_locales_json, " +
664
+ " is_active, archived_at, created_at, updated_at) " +
665
+ "VALUES (?1, ?2, ?3, ?4, 0, NULL, ?5, ?5)",
666
+ [slug, strategy, defaultLocale, supportedJson, ts]
667
+ );
668
+ }
669
+ return _shapePolicy(await _readPolicy(slug));
670
+ }
671
+
672
+ async function getPolicy(slug) {
673
+ return _shapePolicy(await _readPolicy(_slug(slug)));
674
+ }
675
+
676
+ async function listPolicies(input) {
677
+ input = input || {};
678
+ var includeArchived = input.include_archived == null ? false : input.include_archived;
679
+ if (typeof includeArchived !== "boolean") {
680
+ throw new TypeError("localeRouter.listPolicies: include_archived must be a boolean");
681
+ }
682
+ var limit = _limit(input.limit, "limit");
683
+ var sql = "SELECT slug, strategy, default_locale, supported_locales_json, " +
684
+ "is_active, archived_at, created_at, updated_at FROM locale_policies";
685
+ var params = [];
686
+ if (!includeArchived) sql += " WHERE archived_at IS NULL";
687
+ sql += " ORDER BY slug ASC LIMIT ?" + (params.length + 1);
688
+ params.push(limit);
689
+ var r = await query(sql, params);
690
+ var out = [];
691
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_shapePolicy(r.rows[i]));
692
+ return out;
693
+ }
694
+
695
+ async function archivePolicy(slug) {
696
+ var s = _slug(slug);
697
+ var existing = await _readPolicy(s);
698
+ if (!existing) {
699
+ throw new TypeError(
700
+ "localeRouter.archivePolicy: no policy exists for slug " + JSON.stringify(s)
701
+ );
702
+ }
703
+ if (existing.archived_at != null) {
704
+ // Idempotent: an already-archived policy re-archives as a no-op.
705
+ return _shapePolicy(existing);
706
+ }
707
+ var ts = _clampPolicy(s, _now());
708
+ // Archiving an active policy also clears is_active so the
709
+ // partial-unique invariant holds.
710
+ await query(
711
+ "UPDATE locale_policies SET archived_at = ?1, is_active = 0, " +
712
+ "updated_at = ?2 WHERE slug = ?3",
713
+ [ts, ts, s]
714
+ );
715
+ return _shapePolicy(await _readPolicy(s));
716
+ }
717
+
718
+ // ---- active policy -------------------------------------------------
719
+
720
+ async function setActivePolicy(slug) {
721
+ var s = _slug(slug);
722
+ var existing = await _readPolicy(s);
723
+ if (!existing) {
724
+ throw new TypeError(
725
+ "localeRouter.setActivePolicy: no policy exists for slug " + JSON.stringify(s)
726
+ );
727
+ }
728
+ if (existing.archived_at != null) {
729
+ throw new TypeError(
730
+ "localeRouter.setActivePolicy: cannot activate archived policy " +
731
+ JSON.stringify(s) + " — re-define it first"
732
+ );
733
+ }
734
+ var ts = _clampPolicy(s, _now());
735
+ // Demote every other active policy first so the partial-unique
736
+ // index never blocks the activation INSERT/UPDATE. Done in two
737
+ // statements (D1 doesn't run transactional multi-statement
738
+ // batches in the workflow we ship under, but the worst case is
739
+ // an ephemeral "no active policy" window followed by the second
740
+ // update — resolveLocale handles "no active policy" cleanly).
741
+ await query(
742
+ "UPDATE locale_policies SET is_active = 0, updated_at = ?1 " +
743
+ "WHERE is_active = 1 AND slug != ?2",
744
+ [ts, s]
745
+ );
746
+ await query(
747
+ "UPDATE locale_policies SET is_active = 1, updated_at = ?1 " +
748
+ "WHERE slug = ?2",
749
+ [ts, s]
750
+ );
751
+ return _shapePolicy(await _readPolicy(s));
752
+ }
753
+
754
+ async function activePolicy() {
755
+ var r = await query(
756
+ "SELECT slug, strategy, default_locale, supported_locales_json, " +
757
+ "is_active, archived_at, created_at, updated_at " +
758
+ "FROM locale_policies WHERE is_active = 1 LIMIT 1",
759
+ []
760
+ );
761
+ return _shapePolicy(r.rows[0] || null);
762
+ }
763
+
764
+ // ---- customer prefs ------------------------------------------------
765
+
766
+ async function _readCustomerPref(customerId) {
767
+ var r = await query(
768
+ "SELECT customer_id, locale, set_at, updated_at " +
769
+ "FROM customer_locale_prefs WHERE customer_id = ?1 LIMIT 1",
770
+ [customerId]
771
+ );
772
+ return r.rows[0] || null;
773
+ }
774
+
775
+ async function setCustomerLocale(input) {
776
+ if (!input || typeof input !== "object") {
777
+ throw new TypeError("localeRouter.setCustomerLocale: input object required");
778
+ }
779
+ var customerId = _customerId(input.customer_id, "customer_id");
780
+ var loc = _locale(input.locale, "locale");
781
+ // The locale must reference an existing (not necessarily active)
782
+ // catalog row. An archived locale is allowed — operators flipping
783
+ // a buyer's pref to a discontinued locale (audit / historical
784
+ // replay) shouldn't be blocked.
785
+ var row = await _readLocale(loc);
786
+ if (!row) {
787
+ throw new TypeError(
788
+ "localeRouter.setCustomerLocale: locale " + JSON.stringify(loc) +
789
+ " does not reference a known locale"
790
+ );
791
+ }
792
+ var existing = await _readCustomerPref(customerId);
793
+ var ts = _clampCustomer(customerId, _now());
794
+ if (existing) {
795
+ await query(
796
+ "UPDATE customer_locale_prefs SET locale = ?1, updated_at = ?2 " +
797
+ "WHERE customer_id = ?3",
798
+ [loc, ts, customerId]
799
+ );
800
+ } else {
801
+ await query(
802
+ "INSERT INTO customer_locale_prefs " +
803
+ "(customer_id, locale, set_at, updated_at) VALUES (?1, ?2, ?3, ?3)",
804
+ [customerId, loc, ts]
805
+ );
806
+ }
807
+ return _shapeCustomerPref(await _readCustomerPref(customerId));
808
+ }
809
+
810
+ async function clearCustomerLocale(customerId) {
811
+ var cid = _customerId(customerId, "customer_id");
812
+ var r = await query(
813
+ "DELETE FROM customer_locale_prefs WHERE customer_id = ?1",
814
+ [cid]
815
+ );
816
+ return { cleared: Number(r.rowCount || 0) > 0, customer_id: cid };
817
+ }
818
+
819
+ async function getCustomerLocale(customerId) {
820
+ var cid = _customerId(customerId, "customer_id");
821
+ return _shapeCustomerPref(await _readCustomerPref(cid));
822
+ }
823
+
824
+ // ---- resolution ----------------------------------------------------
825
+
826
+ // Walk a candidate tag against the policy's supported list. The
827
+ // candidate matches when:
828
+ // 1. it appears in supported_locales (case-insensitive — BCP-47
829
+ // tags are case-insensitive on the wire);
830
+ // 2. or its primary subtag matches a supported tag's primary
831
+ // subtag (operators who declare only `en` answer requests for
832
+ // `en-GB` with `en`);
833
+ // 3. or the fallback chain on the catalog row leads to one that
834
+ // matches (1) or (2).
835
+ // Returns the resolved supported tag (in its catalog casing) or
836
+ // null when no chain reaches a supported entry.
837
+ async function _resolveCandidate(candidate, supported) {
838
+ if (!candidate) return null;
839
+ var lowered = candidate.toLowerCase();
840
+ var supportedLowerToOriginal = Object.create(null);
841
+ for (var i = 0; i < supported.length; i += 1) {
842
+ supportedLowerToOriginal[supported[i].toLowerCase()] = supported[i];
843
+ }
844
+ if (Object.prototype.hasOwnProperty.call(supportedLowerToOriginal, lowered)) {
845
+ return supportedLowerToOriginal[lowered];
846
+ }
847
+ // Primary-subtag match. `en-US` candidate matches `en` supported.
848
+ var candidatePrimary = lowered.split("-")[0];
849
+ for (var j = 0; j < supported.length; j += 1) {
850
+ var supportedTag = supported[j];
851
+ var supLower = supportedTag.toLowerCase();
852
+ if (supLower === candidatePrimary) return supportedTag;
853
+ }
854
+ // Walk the catalog fallback chain. Bounded depth so a manually
855
+ // poked self-loop doesn't spin forever (the schema FK is best-
856
+ // effort, and the application validators refuse `tag === fallback`
857
+ // but can't refuse `A -> B -> A` without traversing the chain).
858
+ var current = candidate;
859
+ for (var depth = 0; depth < FALLBACK_MAX_DEPTH; depth += 1) {
860
+ var row = await _readLocale(current);
861
+ if (!row || row.fallback == null) return null;
862
+ var nextLower = String(row.fallback).toLowerCase();
863
+ if (Object.prototype.hasOwnProperty.call(supportedLowerToOriginal, nextLower)) {
864
+ return supportedLowerToOriginal[nextLower];
865
+ }
866
+ // Also check primary-subtag of the fallback hop.
867
+ var nextPrimary = nextLower.split("-")[0];
868
+ for (var k = 0; k < supported.length; k += 1) {
869
+ if (supported[k].toLowerCase() === nextPrimary) return supported[k];
870
+ }
871
+ current = row.fallback;
872
+ }
873
+ return null;
874
+ }
875
+
876
+ function _canonicalForUrl(host, path, locale, strategy) {
877
+ // We emit a canonical URL only for the URL-shaped strategies.
878
+ if (strategy !== "url_prefix" && strategy !== "subdomain") return undefined;
879
+ if (strategy === "url_prefix") {
880
+ // Replace any leading locale-shaped path segment with the
881
+ // resolved locale, otherwise prepend it.
882
+ var prefix = _urlPrefixCandidate(path);
883
+ var rest;
884
+ if (prefix) {
885
+ rest = path.slice(1 + prefix.length); // strip "/<prefix>"
886
+ if (rest.length === 0) rest = "/";
887
+ } else {
888
+ rest = path;
889
+ }
890
+ if (rest.charAt(0) !== "/") rest = "/" + rest;
891
+ return "https://" + host + "/" + locale.toLowerCase() + (rest === "/" ? "" : rest);
892
+ }
893
+ // subdomain — replace any leading locale-shaped subdomain
894
+ // segment with the resolved locale, otherwise prepend it. The
895
+ // canonical host carries the locale subdomain even when the
896
+ // request landed on the apex (operators expect a redirect to
897
+ // the canonical form).
898
+ var sub = _subdomainCandidate(host);
899
+ var apex;
900
+ if (sub) {
901
+ apex = host.slice(sub.length + 1);
902
+ } else {
903
+ apex = host;
904
+ }
905
+ return "https://" + locale.toLowerCase() + "." + apex + path;
906
+ }
907
+
908
+ async function resolveLocale(input) {
909
+ if (!input || typeof input !== "object") {
910
+ throw new TypeError("localeRouter.resolveLocale: input object required");
911
+ }
912
+ var req = input.request;
913
+ if (!req || typeof req !== "object") {
914
+ throw new TypeError("localeRouter.resolveLocale: request object required");
915
+ }
916
+ var host = _host(req.host, "request.host");
917
+ var path = _path(req.path, "request.path");
918
+ var acceptLanguage = _headerOpt(req.accept_language, "request.accept_language");
919
+ var cookieLocale = _headerOpt(req.cookie_locale, "request.cookie_locale");
920
+ var customerId = req.customer_id == null
921
+ ? null
922
+ : _customerId(req.customer_id, "request.customer_id");
923
+
924
+ // Per-call validation that the cookie hint (when supplied) is
925
+ // shaped like a locale tag — operators rotating a stale cookie
926
+ // shouldn't crash the resolver.
927
+ if (cookieLocale != null && (!LOCALE_RE.test(cookieLocale) || cookieLocale.length > LOCALE_MAX)) {
928
+ cookieLocale = null;
929
+ }
930
+
931
+ var policy = await activePolicy();
932
+ if (!policy) {
933
+ // No active policy — nothing to resolve against. Operators
934
+ // call setActivePolicy at boot; a deployment that hasn't done
935
+ // so yet gets a clear refusal instead of a silent default.
936
+ throw new Error(
937
+ "localeRouter.resolveLocale: no active policy — call setActivePolicy first"
938
+ );
939
+ }
940
+ var supported = policy.supported_locales;
941
+ var strategy = policy.strategy;
942
+ var defaultLocale = policy.default_locale;
943
+
944
+ // Step 1 — customer_id always wins when a row exists. Strategy
945
+ // `customer_preference` would otherwise have no source of truth
946
+ // other than this row; for every other strategy, an explicit
947
+ // logged-in pick is the truthiest signal we have.
948
+ if (customerId) {
949
+ var pref = await _readCustomerPref(customerId);
950
+ if (pref) {
951
+ var custResolved = await _resolveCandidate(pref.locale, supported);
952
+ if (custResolved) {
953
+ return await _emitResolution(custResolved, "customer_preference", host, path, strategy);
954
+ }
955
+ }
956
+ }
957
+
958
+ // Step 2 — strategy-specific request hint.
959
+ var hintLocale = null;
960
+ var hintSource = null;
961
+ if (strategy === "url_prefix") {
962
+ hintLocale = _urlPrefixCandidate(path);
963
+ hintSource = "url_prefix";
964
+ } else if (strategy === "subdomain") {
965
+ hintLocale = _subdomainCandidate(host);
966
+ hintSource = "subdomain";
967
+ } else if (strategy === "cookie") {
968
+ hintLocale = cookieLocale;
969
+ hintSource = "cookie";
970
+ }
971
+ if (hintLocale) {
972
+ var hintResolved = await _resolveCandidate(hintLocale, supported);
973
+ if (hintResolved) {
974
+ return await _emitResolution(hintResolved, hintSource, host, path, strategy);
975
+ }
976
+ }
977
+
978
+ // Step 3 — Accept-Language walk. Every strategy falls back through
979
+ // the browser's stated preference list before landing on the
980
+ // policy default.
981
+ if (acceptLanguage) {
982
+ var alEntries = _parseAcceptLanguage(acceptLanguage);
983
+ for (var i = 0; i < alEntries.length; i += 1) {
984
+ var alResolved = await _resolveCandidate(alEntries[i].tag, supported);
985
+ if (alResolved) {
986
+ return await _emitResolution(alResolved, "accept_language", host, path, strategy);
987
+ }
988
+ }
989
+ }
990
+
991
+ // Step 4 — the policy's default locale.
992
+ return await _emitResolution(defaultLocale, "default", host, path, strategy);
993
+ }
994
+
995
+ async function _emitResolution(locale, source, host, path, strategy) {
996
+ var out = { locale: locale, source: source };
997
+ var canonical = _canonicalForUrl(host, path, locale, strategy);
998
+ if (canonical !== undefined) out.canonical_url = canonical;
999
+
1000
+ var id = _b().uuid.v7();
1001
+ var ts = _clampLog(_now());
1002
+ await query(
1003
+ "INSERT INTO locale_resolutions_log " +
1004
+ "(id, locale, source, host, path, occurred_at) " +
1005
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1006
+ [id, locale, source, host, path, ts]
1007
+ );
1008
+ return out;
1009
+ }
1010
+
1011
+ // ---- popularity / audit --------------------------------------------
1012
+
1013
+ async function localePopularity(input) {
1014
+ if (!input || typeof input !== "object") {
1015
+ throw new TypeError("localeRouter.localePopularity: input object required");
1016
+ }
1017
+ var from = _epochMs(input.from, "from");
1018
+ var to = _epochMs(input.to, "to");
1019
+ var limit = _limit(input.limit, "limit");
1020
+ if (from != null && to != null && from > to) {
1021
+ throw new TypeError("localeRouter.localePopularity: from must be <= to");
1022
+ }
1023
+ var sql = "SELECT locale, COUNT(*) AS hits FROM locale_resolutions_log";
1024
+ var params = [];
1025
+ var where = [];
1026
+ if (from != null) {
1027
+ where.push("occurred_at >= ?" + (params.length + 1));
1028
+ params.push(from);
1029
+ }
1030
+ if (to != null) {
1031
+ where.push("occurred_at <= ?" + (params.length + 1));
1032
+ params.push(to);
1033
+ }
1034
+ if (where.length) sql += " WHERE " + where.join(" AND ");
1035
+ sql += " GROUP BY locale ORDER BY hits DESC, locale ASC LIMIT ?" +
1036
+ (params.length + 1);
1037
+ params.push(limit);
1038
+ var r = await query(sql, params);
1039
+ var out = [];
1040
+ for (var i = 0; i < r.rows.length; i += 1) {
1041
+ out.push({
1042
+ locale: r.rows[i].locale,
1043
+ hits: Number(r.rows[i].hits),
1044
+ });
1045
+ }
1046
+ return out;
1047
+ }
1048
+
1049
+ return {
1050
+ KINDS: KINDS.slice(),
1051
+ STRATEGIES: STRATEGIES.slice(),
1052
+ LOCALE_RE: LOCALE_RE,
1053
+ CURRENCY_RE: CURRENCY_RE,
1054
+ defineLocale: defineLocale,
1055
+ getLocale: getLocale,
1056
+ listLocales: listLocales,
1057
+ definePolicy: definePolicy,
1058
+ getPolicy: getPolicy,
1059
+ listPolicies: listPolicies,
1060
+ archivePolicy: archivePolicy,
1061
+ setActivePolicy: setActivePolicy,
1062
+ activePolicy: activePolicy,
1063
+ setCustomerLocale: setCustomerLocale,
1064
+ clearCustomerLocale: clearCustomerLocale,
1065
+ getCustomerLocale: getCustomerLocale,
1066
+ resolveLocale: resolveLocale,
1067
+ localePopularity: localePopularity,
1068
+ };
1069
+ }
1070
+
1071
+ module.exports = {
1072
+ create: create,
1073
+ KINDS: KINDS,
1074
+ STRATEGIES: STRATEGIES,
1075
+ LOCALE_RE: LOCALE_RE,
1076
+ CURRENCY_RE: CURRENCY_RE,
1077
+ };