@blamejs/blamejs-shop 0.0.65 → 0.0.70

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 (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,711 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.themeAssets
4
+ * @title Theme assets — operator-uploaded artifacts referenced by themes
5
+ *
6
+ * @intro
7
+ * Operators upload theme artifacts (fonts, logo variants, hero
8
+ * images, favicons, banner / og / icon images, plus a `custom`
9
+ * escape hatch) and assign them to a theme. The render-time picker
10
+ * dispatches off the `kind` enum and the `theme_slug` assignment:
11
+ * the active theme's header asks for `kind=logo`, the storefront
12
+ * resolves the single logo assigned to the active theme.
13
+ *
14
+ * Distinct from the `theme` primitive — that one manages the active
15
+ * theme slug + template directory. This is the asset catalog +
16
+ * content-addressed storage references.
17
+ *
18
+ * Each asset carries:
19
+ * - a stable URL-friendly `slug` (PK),
20
+ * - a `kind` token from a closed enum (font / logo / hero_image /
21
+ * favicon / banner_image / og_image / icon / custom),
22
+ * - an IANA `content_type` (e.g. `font/woff2`, `image/svg+xml`),
23
+ * - a `sha3_512` digest of the asset bytes — validated as a
24
+ * 128-char lowercase hex shape at the app layer; backs the
25
+ * upload de-dupe lookup,
26
+ * - a non-negative `byte_size`,
27
+ * - a `source_url` — https-only or /-rooted internal path,
28
+ * validated through `b.safeUrl` at write time,
29
+ * - optional `alt_text` for image kinds,
30
+ * - optional `theme_slug` — NULL while the asset sits in the
31
+ * library; set by `assignToTheme` when the operator wires it
32
+ * into a theme,
33
+ * - `archived_at` — soft delete; archived assets stay in the
34
+ * table so a clone or audit can resolve the slug but drop out
35
+ * of `assetsForTheme` / `assetsByKind`,
36
+ * - `impression_count` — denormalized counter the hot-path
37
+ * `recordImpression` bumps; `metricsForAsset` reads it in
38
+ * addition to scanning the impression-event log.
39
+ *
40
+ * Composes:
41
+ * - `b.safeUrl.parse` — `source_url` is HTTPS-only at the app
42
+ * layer (or a /-rooted absolute path for storefront-internal
43
+ * routes). javascript: / data: / vbscript: refused before the
44
+ * row is persisted.
45
+ * - Per-factory monotonic clock — guarantees `created_at` /
46
+ * `updated_at` / `occurred_at` are strictly increasing so a
47
+ * tied `created_at` still sorts deterministically when two
48
+ * writes land in the same millisecond.
49
+ *
50
+ * Surface:
51
+ * - `create({ query? })` — factory. `query` is optional; absent
52
+ * it, the primitive talks to `b.externalDb.query` directly.
53
+ * - `registerAsset({ slug, kind, content_type, sha3_512,
54
+ * byte_size, source_url, alt_text?,
55
+ * theme_slug? })` — insert an asset row.
56
+ * - `getAsset(slug)` — single row, any archive state.
57
+ * - `assetsForTheme(theme_slug)` — enumerate active assets
58
+ * assigned to a given theme.
59
+ * - `assetsByKind({ kind, theme_slug? })` — enumerate active
60
+ * assets by kind. `theme_slug` filter is optional; when
61
+ * omitted, returns every active asset of that kind regardless
62
+ * of theme assignment (the asset library scan).
63
+ * - `updateAsset(slug, patch)` — patch any of `content_type /
64
+ * source_url / alt_text / byte_size / sha3_512`.
65
+ * - `archiveAsset(slug)` — stamp `archived_at`. Idempotent-
66
+ * refusal on re-archive.
67
+ * - `assignToTheme({ slug, theme_slug })` — swap an asset's
68
+ * theme assignment. Pass `theme_slug: null` to detach.
69
+ * - `recordImpression(slug)` — drop-silent hot-path counter
70
+ * bump on the storefront render that resolves the asset.
71
+ * - `metricsForAsset({ slug, from, to })` — windowed impression
72
+ * count + lifetime denormalized count.
73
+ * - `cleanupOrphans({ days_idle })` — archive every active asset
74
+ * with `theme_slug IS NULL` whose `updated_at` is older than
75
+ * `now - days_idle * 86400000`. Returns the count of archived
76
+ * rows.
77
+ *
78
+ * Storage:
79
+ * - `theme_assets` + `theme_asset_impressions`
80
+ * (migration `0131_theme_assets.sql`).
81
+ *
82
+ * @primitive themeAssets
83
+ * @related b.safeUrl, b.uuid.v7
84
+ */
85
+
86
+ var MAX_SLUG_LEN = 80;
87
+ var MAX_THEME_SLUG_LEN = 80;
88
+ var MAX_CONTENT_TYPE_LEN = 128;
89
+ var MAX_SOURCE_URL_LEN = 2048;
90
+ var MAX_ALT_TEXT_LEN = 500;
91
+ var SHA3_512_HEX_LEN = 128;
92
+
93
+ var ALLOWED_KINDS = Object.freeze([
94
+ "font",
95
+ "logo",
96
+ "hero_image",
97
+ "favicon",
98
+ "banner_image",
99
+ "og_image",
100
+ "icon",
101
+ "custom",
102
+ ]);
103
+
104
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
105
+ "content_type",
106
+ "source_url",
107
+ "alt_text",
108
+ "byte_size",
109
+ "sha3_512",
110
+ ]);
111
+
112
+ // Slug shape mirrors the rest of the storefront primitives — alnum
113
+ // leading character, alnum + dot + hyphen + underscore tail, capped
114
+ // length. Reaches operator logs + admin URLs.
115
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
116
+ var THEME_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
117
+
118
+ // Lowercase hex, exactly 128 chars for a SHA3-512 digest. The
119
+ // content-addressed lookup is byte-exact, so the canonical form is
120
+ // lowercase — uppercase / mixed-case inputs refused so the upload
121
+ // pipeline doesn't end up with two index entries for the same bytes.
122
+ var SHA3_512_HEX_RE = /^[0-9a-f]{128}$/;
123
+
124
+ // IANA media type shape — `type/subtype` with the standard token
125
+ // alphabet on each side, optional `; param=value` suffix. The intent
126
+ // isn't full RFC 7231 conformance — it's "refuse obvious junk before
127
+ // the storefront tries to set a Content-Type header from it".
128
+ var CONTENT_TYPE_RE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+(?:\s*;\s*[A-Za-z0-9!#$&^_.+-]+\s*=\s*(?:"[^"\x00-\x1f]*"|[A-Za-z0-9!#$&^_.+-]+))*$/;
129
+
130
+ // Refuse C0 control bytes + DEL in operator-authored text fields.
131
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
132
+
133
+ // Zero-width / direction-override family — mirrors the rest of the
134
+ // shop primitives. Spelled with \u-escapes so ESLint's
135
+ // no-irregular-whitespace stays happy.
136
+ var ZERO_WIDTH_RE = new RegExp(
137
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
138
+ );
139
+
140
+ var MS_PER_DAY = 86400000;
141
+
142
+ var bShop;
143
+ function _b() {
144
+ if (!bShop) bShop = require("./index");
145
+ return bShop.framework;
146
+ }
147
+
148
+ // ---- validators ---------------------------------------------------------
149
+
150
+ function _slug(s, label) {
151
+ label = label || "slug";
152
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
153
+ throw new TypeError(
154
+ "themeAssets: " + label +
155
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)"
156
+ );
157
+ }
158
+ return s;
159
+ }
160
+
161
+ function _themeSlug(s) {
162
+ if (s == null) return null;
163
+ if (typeof s !== "string" || !THEME_SLUG_RE.test(s)) {
164
+ throw new TypeError(
165
+ "themeAssets: theme_slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ " +
166
+ "(<= " + MAX_THEME_SLUG_LEN + " chars) or be null"
167
+ );
168
+ }
169
+ return s;
170
+ }
171
+
172
+ function _kind(s) {
173
+ if (typeof s !== "string" || ALLOWED_KINDS.indexOf(s) === -1) {
174
+ throw new TypeError(
175
+ "themeAssets: kind must be one of " + JSON.stringify(ALLOWED_KINDS)
176
+ );
177
+ }
178
+ return s;
179
+ }
180
+
181
+ function _contentType(s) {
182
+ if (typeof s !== "string" || !s.length || s.length > MAX_CONTENT_TYPE_LEN) {
183
+ throw new TypeError(
184
+ "themeAssets: content_type must be a non-empty IANA media type <= " +
185
+ MAX_CONTENT_TYPE_LEN + " chars"
186
+ );
187
+ }
188
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
189
+ throw new TypeError("themeAssets: content_type contains control bytes");
190
+ }
191
+ if (!CONTENT_TYPE_RE.test(s)) {
192
+ throw new TypeError(
193
+ "themeAssets: content_type " + JSON.stringify(s) +
194
+ " is not a valid `type/subtype` shape"
195
+ );
196
+ }
197
+ return s;
198
+ }
199
+
200
+ function _sha3_512(s) {
201
+ if (typeof s !== "string" || s.length !== SHA3_512_HEX_LEN) {
202
+ throw new TypeError(
203
+ "themeAssets: sha3_512 must be a " + SHA3_512_HEX_LEN +
204
+ "-char lowercase hex string"
205
+ );
206
+ }
207
+ if (!SHA3_512_HEX_RE.test(s)) {
208
+ throw new TypeError(
209
+ "themeAssets: sha3_512 must be lowercase hex (0-9a-f), 128 chars"
210
+ );
211
+ }
212
+ return s;
213
+ }
214
+
215
+ function _byteSize(n) {
216
+ if (typeof n !== "number" || !isFinite(n) || n < 0 || Math.floor(n) !== n) {
217
+ throw new TypeError(
218
+ "themeAssets: byte_size must be a non-negative integer"
219
+ );
220
+ }
221
+ return n;
222
+ }
223
+
224
+ // `source_url` — HTTPS-only via `b.safeUrl.parse` OR a /-rooted
225
+ // absolute path for storefront-internal routes. Protocol-relative
226
+ // `//host/...` URLs are refused — the operator must commit to https
227
+ // explicitly so a CDN mis-config can't downgrade the asset link.
228
+ // javascript: / data: / vbscript: are refused by safeUrl's default
229
+ // protocol allowlist.
230
+ function _sourceUrl(s) {
231
+ if (typeof s !== "string" || !s.length || s.length > MAX_SOURCE_URL_LEN) {
232
+ throw new TypeError(
233
+ "themeAssets: source_url must be a non-empty string <= " +
234
+ MAX_SOURCE_URL_LEN + " chars"
235
+ );
236
+ }
237
+ if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
238
+ throw new TypeError("themeAssets: source_url contains control / zero-width bytes");
239
+ }
240
+ if (s.charCodeAt(0) === 47 /* "/" */) {
241
+ if (s.length > 1 && s.charCodeAt(1) === 47) {
242
+ throw new TypeError(
243
+ "themeAssets: source_url protocol-relative `//host/...` refused — " +
244
+ "use absolute https:// or a /-rooted absolute path"
245
+ );
246
+ }
247
+ if (s.indexOf("..") !== -1) {
248
+ throw new TypeError("themeAssets: source_url path must not contain '..'");
249
+ }
250
+ return s;
251
+ }
252
+ try {
253
+ _b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
254
+ } catch (e) {
255
+ throw new TypeError(
256
+ "themeAssets: source_url — " +
257
+ (e && e.message || "must be https:// or a /-rooted absolute path")
258
+ );
259
+ }
260
+ return s;
261
+ }
262
+
263
+ function _altText(s) {
264
+ if (s == null) return null;
265
+ if (typeof s !== "string" || !s.length || s.length > MAX_ALT_TEXT_LEN) {
266
+ throw new TypeError(
267
+ "themeAssets: alt_text must be a non-empty string <= " +
268
+ MAX_ALT_TEXT_LEN + " chars (or null)"
269
+ );
270
+ }
271
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
272
+ throw new TypeError("themeAssets: alt_text contains control bytes (incl. CR/LF)");
273
+ }
274
+ if (ZERO_WIDTH_RE.test(s)) {
275
+ throw new TypeError("themeAssets: alt_text contains zero-width / direction-override characters");
276
+ }
277
+ return s;
278
+ }
279
+
280
+ function _epochMs(n, label) {
281
+ if (typeof n !== "number" || !isFinite(n) || n < 0 || Math.floor(n) !== n) {
282
+ throw new TypeError(
283
+ "themeAssets: " + label + " must be a non-negative integer epoch-ms"
284
+ );
285
+ }
286
+ return n;
287
+ }
288
+
289
+ function _window(from, to) {
290
+ _epochMs(from, "from");
291
+ _epochMs(to, "to");
292
+ if (to <= from) {
293
+ throw new TypeError(
294
+ "themeAssets: to (" + to + ") must be > from (" + from + ")"
295
+ );
296
+ }
297
+ return { from: from, to: to };
298
+ }
299
+
300
+ // Per-factory monotonic clock — guarantees `created_at` /
301
+ // `updated_at` / `occurred_at` are strictly increasing so a tied
302
+ // `created_at` still sorts deterministically when two writes land in
303
+ // the same millisecond. Mirrors the shape used by cookie-consent /
304
+ // discount-analytics / sms-dispatcher / etc.
305
+ var _lastTs = 0;
306
+ function _now() {
307
+ var t = Date.now();
308
+ if (t <= _lastTs) { t = _lastTs + 1; }
309
+ _lastTs = t;
310
+ return t;
311
+ }
312
+
313
+ // ---- row hydration ------------------------------------------------------
314
+
315
+ function _hydrateRow(r) {
316
+ if (!r) return null;
317
+ return {
318
+ slug: r.slug,
319
+ kind: r.kind,
320
+ content_type: r.content_type,
321
+ sha3_512: r.sha3_512,
322
+ byte_size: Number(r.byte_size),
323
+ source_url: r.source_url,
324
+ alt_text: r.alt_text == null ? null : r.alt_text,
325
+ theme_slug: r.theme_slug == null ? null : r.theme_slug,
326
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
327
+ impression_count: Number(r.impression_count),
328
+ created_at: Number(r.created_at),
329
+ updated_at: Number(r.updated_at),
330
+ };
331
+ }
332
+
333
+ // ---- factory ------------------------------------------------------------
334
+
335
+ function create(opts) {
336
+ opts = opts || {};
337
+ var query = opts.query;
338
+ if (!query) {
339
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
340
+ }
341
+
342
+ // -- registerAsset ----------------------------------------------------
343
+
344
+ async function registerAsset(input) {
345
+ if (!input || typeof input !== "object") {
346
+ throw new TypeError("themeAssets.registerAsset: input object required");
347
+ }
348
+ var slug = _slug(input.slug);
349
+ var kind = _kind(input.kind);
350
+ var contentType = _contentType(input.content_type);
351
+ var sha3 = _sha3_512(input.sha3_512);
352
+ var byteSize = _byteSize(input.byte_size);
353
+ var sourceUrl = _sourceUrl(input.source_url);
354
+ var altText = _altText(input.alt_text);
355
+ var themeSlug = _themeSlug(input.theme_slug);
356
+
357
+ var ts = _now();
358
+ try {
359
+ await query(
360
+ "INSERT INTO theme_assets " +
361
+ "(slug, kind, content_type, sha3_512, byte_size, source_url, " +
362
+ " alt_text, theme_slug, archived_at, impression_count, " +
363
+ " created_at, updated_at) " +
364
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0, ?9, ?9)",
365
+ [slug, kind, contentType, sha3, byteSize, sourceUrl,
366
+ altText, themeSlug, ts],
367
+ );
368
+ } catch (e) {
369
+ var msg = (e && e.message || "").toLowerCase();
370
+ if (msg.indexOf("unique") !== -1 || msg.indexOf("primary key") !== -1) {
371
+ throw new TypeError(
372
+ "themeAssets.registerAsset: slug " + JSON.stringify(slug) +
373
+ " already exists"
374
+ );
375
+ }
376
+ throw e;
377
+ }
378
+ return await getAsset(slug);
379
+ }
380
+
381
+ // -- getAsset ---------------------------------------------------------
382
+
383
+ async function getAsset(slug) {
384
+ _slug(slug);
385
+ var r = (await query(
386
+ "SELECT * FROM theme_assets WHERE slug = ?1 LIMIT 1",
387
+ [slug],
388
+ )).rows[0];
389
+ return _hydrateRow(r);
390
+ }
391
+
392
+ // -- assetsForTheme ---------------------------------------------------
393
+
394
+ async function assetsForTheme(themeSlug) {
395
+ if (typeof themeSlug !== "string" || !THEME_SLUG_RE.test(themeSlug)) {
396
+ throw new TypeError(
397
+ "themeAssets.assetsForTheme: theme_slug must match " +
398
+ "/^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_THEME_SLUG_LEN + " chars)"
399
+ );
400
+ }
401
+ var rows = (await query(
402
+ "SELECT * FROM theme_assets " +
403
+ " WHERE theme_slug = ?1 AND archived_at IS NULL " +
404
+ " ORDER BY kind ASC, created_at ASC, slug ASC",
405
+ [themeSlug],
406
+ )).rows;
407
+ return rows.map(_hydrateRow);
408
+ }
409
+
410
+ // -- assetsByKind -----------------------------------------------------
411
+
412
+ async function assetsByKind(listOpts) {
413
+ if (!listOpts || typeof listOpts !== "object") {
414
+ throw new TypeError("themeAssets.assetsByKind: input object required");
415
+ }
416
+ var kind = _kind(listOpts.kind);
417
+ var hasTheme = listOpts.theme_slug !== undefined;
418
+ var themeSlug = hasTheme ? _themeSlug(listOpts.theme_slug) : undefined;
419
+
420
+ var sql = "SELECT * FROM theme_assets WHERE kind = ?1 AND archived_at IS NULL";
421
+ var params = [kind];
422
+ if (hasTheme) {
423
+ if (themeSlug == null) {
424
+ sql += " AND theme_slug IS NULL";
425
+ } else {
426
+ sql += " AND theme_slug = ?2";
427
+ params.push(themeSlug);
428
+ }
429
+ }
430
+ sql += " ORDER BY created_at ASC, slug ASC";
431
+ var rows = (await query(sql, params)).rows;
432
+ return rows.map(_hydrateRow);
433
+ }
434
+
435
+ // -- updateAsset ------------------------------------------------------
436
+
437
+ async function updateAsset(slug, patch) {
438
+ _slug(slug);
439
+ if (!patch || typeof patch !== "object") {
440
+ throw new TypeError("themeAssets.updateAsset: patch object required");
441
+ }
442
+ var keys = Object.keys(patch);
443
+ if (!keys.length) {
444
+ throw new TypeError(
445
+ "themeAssets.updateAsset: patch must include at least one column"
446
+ );
447
+ }
448
+
449
+ var current = await getAsset(slug);
450
+ if (!current) {
451
+ throw new TypeError(
452
+ "themeAssets.updateAsset: slug " + JSON.stringify(slug) + " not found"
453
+ );
454
+ }
455
+ if (current.archived_at != null) {
456
+ throw new TypeError(
457
+ "themeAssets.updateAsset: slug " + JSON.stringify(slug) +
458
+ " is archived — register a new asset to replace it"
459
+ );
460
+ }
461
+
462
+ var sets = [];
463
+ var params = [];
464
+ var idx = 1;
465
+ for (var i = 0; i < keys.length; i += 1) {
466
+ var col = keys[i];
467
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
468
+ throw new TypeError(
469
+ "themeAssets.updateAsset: unsupported column " + JSON.stringify(col)
470
+ );
471
+ }
472
+ if (col === "content_type") {
473
+ sets.push("content_type = ?" + idx);
474
+ params.push(_contentType(patch[col]));
475
+ } else if (col === "source_url") {
476
+ sets.push("source_url = ?" + idx);
477
+ params.push(_sourceUrl(patch[col]));
478
+ } else if (col === "alt_text") {
479
+ sets.push("alt_text = ?" + idx);
480
+ params.push(_altText(patch[col]));
481
+ } else if (col === "byte_size") {
482
+ sets.push("byte_size = ?" + idx);
483
+ params.push(_byteSize(patch[col]));
484
+ } else /* sha3_512 */ {
485
+ sets.push("sha3_512 = ?" + idx);
486
+ params.push(_sha3_512(patch[col]));
487
+ }
488
+ idx += 1;
489
+ }
490
+ sets.push("updated_at = ?" + idx);
491
+ params.push(_now());
492
+ idx += 1;
493
+
494
+ params.push(slug);
495
+ var r = await query(
496
+ "UPDATE theme_assets SET " + sets.join(", ") + " WHERE slug = ?" + idx,
497
+ params,
498
+ );
499
+ if (Number(r.rowCount || 0) === 0) {
500
+ throw new TypeError(
501
+ "themeAssets.updateAsset: slug " + JSON.stringify(slug) + " not found"
502
+ );
503
+ }
504
+ return await getAsset(slug);
505
+ }
506
+
507
+ // -- archiveAsset -----------------------------------------------------
508
+
509
+ async function archiveAsset(slug) {
510
+ _slug(slug);
511
+ var current = await getAsset(slug);
512
+ if (!current) {
513
+ throw new TypeError(
514
+ "themeAssets.archiveAsset: slug " + JSON.stringify(slug) + " not found"
515
+ );
516
+ }
517
+ if (current.archived_at != null) {
518
+ throw new TypeError(
519
+ "themeAssets.archiveAsset: slug " + JSON.stringify(slug) +
520
+ " is already archived"
521
+ );
522
+ }
523
+ var ts = _now();
524
+ var r = await query(
525
+ "UPDATE theme_assets SET archived_at = ?1, updated_at = ?1 " +
526
+ " WHERE slug = ?2 AND archived_at IS NULL",
527
+ [ts, slug],
528
+ );
529
+ if (Number(r.rowCount || 0) === 0) {
530
+ throw new TypeError(
531
+ "themeAssets.archiveAsset: slug " + JSON.stringify(slug) +
532
+ " transition race"
533
+ );
534
+ }
535
+ return await getAsset(slug);
536
+ }
537
+
538
+ // -- assignToTheme ----------------------------------------------------
539
+ //
540
+ // Swap an asset's theme assignment. Pass `theme_slug: null` to
541
+ // detach (returning the asset to the library). Refuses to swap an
542
+ // archived asset — the operator has to register a new asset to
543
+ // replace it.
544
+
545
+ async function assignToTheme(input) {
546
+ if (!input || typeof input !== "object") {
547
+ throw new TypeError("themeAssets.assignToTheme: input object required");
548
+ }
549
+ var slug = _slug(input.slug);
550
+ if (!Object.prototype.hasOwnProperty.call(input, "theme_slug")) {
551
+ throw new TypeError(
552
+ "themeAssets.assignToTheme: theme_slug required (string or null)"
553
+ );
554
+ }
555
+ var themeSlug = _themeSlug(input.theme_slug);
556
+
557
+ var current = await getAsset(slug);
558
+ if (!current) {
559
+ throw new TypeError(
560
+ "themeAssets.assignToTheme: slug " + JSON.stringify(slug) + " not found"
561
+ );
562
+ }
563
+ if (current.archived_at != null) {
564
+ throw new TypeError(
565
+ "themeAssets.assignToTheme: slug " + JSON.stringify(slug) +
566
+ " is archived — register a new asset to replace it"
567
+ );
568
+ }
569
+ var ts = _now();
570
+ var r = await query(
571
+ "UPDATE theme_assets SET theme_slug = ?1, updated_at = ?2 " +
572
+ " WHERE slug = ?3 AND archived_at IS NULL",
573
+ [themeSlug, ts, slug],
574
+ );
575
+ if (Number(r.rowCount || 0) === 0) {
576
+ throw new TypeError(
577
+ "themeAssets.assignToTheme: slug " + JSON.stringify(slug) +
578
+ " transition race"
579
+ );
580
+ }
581
+ return await getAsset(slug);
582
+ }
583
+
584
+ // -- recordImpression -------------------------------------------------
585
+ //
586
+ // Drop-silent on bad slug / missing row / archived row — runs on
587
+ // the hot storefront request path (the asset resolves on every
588
+ // page render that surfaces it). Throwing here would crash the
589
+ // response the counter is observing.
590
+
591
+ async function recordImpression(slug) {
592
+ if (typeof slug !== "string" || !SLUG_RE.test(slug)) {
593
+ return { recorded: false };
594
+ }
595
+ try {
596
+ var b = _b();
597
+ var ts = _now();
598
+ var id = b.uuid.v7();
599
+ var bump = await query(
600
+ "UPDATE theme_assets SET impression_count = impression_count + 1, " +
601
+ " updated_at = ?1 WHERE slug = ?2 AND archived_at IS NULL",
602
+ [ts, slug],
603
+ );
604
+ if (Number(bump.rowCount || 0) === 0) return { recorded: false };
605
+ await query(
606
+ "INSERT INTO theme_asset_impressions (id, asset_slug, occurred_at) " +
607
+ " VALUES (?1, ?2, ?3)",
608
+ [id, slug, ts],
609
+ );
610
+ return { recorded: true, id: id };
611
+ } catch (_e) {
612
+ return { recorded: false };
613
+ }
614
+ }
615
+
616
+ // -- metricsForAsset --------------------------------------------------
617
+
618
+ async function metricsForAsset(input) {
619
+ if (!input || typeof input !== "object") {
620
+ throw new TypeError("themeAssets.metricsForAsset: input object required");
621
+ }
622
+ var slug = _slug(input.slug);
623
+ var window = _window(input.from, input.to);
624
+
625
+ var asset = await getAsset(slug);
626
+ if (!asset) {
627
+ throw new TypeError(
628
+ "themeAssets.metricsForAsset: slug " + JSON.stringify(slug) + " not found"
629
+ );
630
+ }
631
+ var row = (await query(
632
+ "SELECT COUNT(*) AS impressions " +
633
+ " FROM theme_asset_impressions " +
634
+ " WHERE asset_slug = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
635
+ [slug, window.from, window.to],
636
+ )).rows[0] || {};
637
+ return {
638
+ slug: slug,
639
+ from: window.from,
640
+ to: window.to,
641
+ impressions: Number(row.impressions) || 0,
642
+ lifetime_impressions: asset.impression_count,
643
+ };
644
+ }
645
+
646
+ // -- cleanupOrphans ---------------------------------------------------
647
+ //
648
+ // Archive every active asset with `theme_slug IS NULL` whose
649
+ // `updated_at` is older than `now - days_idle * 86400000`. Returns
650
+ // the count of archived rows. The intent is the "asset library
651
+ // hygiene" path: assets the operator uploaded but never assigned
652
+ // to a theme accumulate over time; this primitive batch-archives
653
+ // the stale ones without touching anything that's currently in
654
+ // use.
655
+
656
+ async function cleanupOrphans(input) {
657
+ if (!input || typeof input !== "object") {
658
+ throw new TypeError("themeAssets.cleanupOrphans: input object required");
659
+ }
660
+ var days = input.days_idle;
661
+ if (typeof days !== "number" || !isFinite(days) || days < 0 || Math.floor(days) !== days) {
662
+ throw new TypeError(
663
+ "themeAssets.cleanupOrphans: days_idle must be a non-negative integer"
664
+ );
665
+ }
666
+ var ts = _now();
667
+ var cutoff = ts - days * MS_PER_DAY;
668
+ var r = await query(
669
+ "UPDATE theme_assets SET archived_at = ?1, updated_at = ?1 " +
670
+ " WHERE archived_at IS NULL " +
671
+ " AND theme_slug IS NULL " +
672
+ " AND updated_at < ?2",
673
+ [ts, cutoff],
674
+ );
675
+ return { archived: Number(r.rowCount || 0) };
676
+ }
677
+
678
+ return {
679
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
680
+ MAX_THEME_SLUG_LEN: MAX_THEME_SLUG_LEN,
681
+ MAX_CONTENT_TYPE_LEN: MAX_CONTENT_TYPE_LEN,
682
+ MAX_SOURCE_URL_LEN: MAX_SOURCE_URL_LEN,
683
+ MAX_ALT_TEXT_LEN: MAX_ALT_TEXT_LEN,
684
+ SHA3_512_HEX_LEN: SHA3_512_HEX_LEN,
685
+ ALLOWED_KINDS: ALLOWED_KINDS,
686
+ ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
687
+
688
+ registerAsset: registerAsset,
689
+ getAsset: getAsset,
690
+ assetsForTheme: assetsForTheme,
691
+ assetsByKind: assetsByKind,
692
+ updateAsset: updateAsset,
693
+ archiveAsset: archiveAsset,
694
+ assignToTheme: assignToTheme,
695
+ recordImpression: recordImpression,
696
+ metricsForAsset: metricsForAsset,
697
+ cleanupOrphans: cleanupOrphans,
698
+ };
699
+ }
700
+
701
+ module.exports = {
702
+ create: create,
703
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
704
+ MAX_THEME_SLUG_LEN: MAX_THEME_SLUG_LEN,
705
+ MAX_CONTENT_TYPE_LEN: MAX_CONTENT_TYPE_LEN,
706
+ MAX_SOURCE_URL_LEN: MAX_SOURCE_URL_LEN,
707
+ MAX_ALT_TEXT_LEN: MAX_ALT_TEXT_LEN,
708
+ SHA3_512_HEX_LEN: SHA3_512_HEX_LEN,
709
+ ALLOWED_KINDS: ALLOWED_KINDS,
710
+ ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
711
+ };