@blamejs/blamejs-shop 0.0.70 → 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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.0.x
10
10
 
11
+ - v0.0.72 (2026-05-22) — **`loyaltyEarnRules` primitive — per-action point earning rules.** `loyaltyEarnRules` defines HOW points are earned across eight trigger events: `per_dollar_spent`, `per_purchase`, `per_review`, `per_referral_redeemed`, `birthday`, `signup_bonus`, `first_purchase`, `abandoned_cart_recovered`. Distinct from the existing `loyalty` (ledger) and `tierBenefits` (perks) primitives. **Added:** *`loyaltyEarnRules` primitive — per-action point earning rules with optional per-event caps* — `bShop.loyaltyEarnRules.create({ query?, loyalty? })` returns `{ defineRule, getRule, listRules, updateRule, archiveRule, evaluateForEvent, awardForEvent, metricsForRule, applyBatch }`. Triggers: `per_dollar_spent / per_purchase / per_review / per_referral_redeemed / birthday / signup_bonus / first_purchase / abandoned_cart_recovered`. `evaluateForEvent` returns the points calculation (multiply + floor + zero-floor); `awardForEvent` composes the injected `loyalty.earn` handle and rolls back the audit row on ledger failure. Per-event cap (`max_per_event`) clips runaway awards. `customer_status_in` allowlist filters which customer tiers a rule applies to. UNIQUE(`rule_slug`, `customer_id`, `trigger_event_ref`) dedups re-submitted events. `applyBatch` runs multiple awards atomically with partial-failure reporting. Migration `0163_loyalty_earn_rules.sql`.
12
+
13
+ - v0.0.71 (2026-05-22) — **`splitShipments` — monotonic `proposed_at` so back-to-back plans sort newest-first.** Single-fix release. `splitShipments.planSplit` calls landing in the same wall-clock millisecond now get strictly-increasing `proposed_at` stamps via a process-local monotonic clock. Without the fix, `splitsForOrder(order_id)` returned the rows in nondeterministic order under burst-write conditions — CI runners hit the collision more reliably than local clocks. **Fixed:** *`splitShipments` — process-local monotonic `_now` so consecutive `planSplit` calls sort deterministically* — Two `planSplit` calls inside the same millisecond shared a `proposed_at` value, and the secondary tiebreaker (UUID v7 id) was scrambled by the intra-ms random suffix. The primitive now bumps `_now()` by 1ms when `Date.now()` returns a stale value, so `splitsForOrder` reliably returns newest-first under any insert burstiness. Same fix pattern as `orderNotes` / `productBulkOps` / `cookieConsent` / `liveChat` / `sms-dispatcher` / others — applies cleanly to any primitive where `Date.now()` precision drives sort order.
14
+
11
15
  - v0.0.70 (2026-05-22) — **Six new primitives: click and collect, customer surveys, email templates, knowledge base, pixel events, sitemap generator.** Six primitives ship in one release covering store pickup (`clickAndCollect`), customer feedback (`customerSurveys`), operator-editable transactional email templates (`emailTemplates`), self-serve help center (`knowledgeBase`), server-side conversion pixel tracking (`pixelEvents`), and storefront sitemap generation (`sitemapGenerator`). **Added:** *`clickAndCollect` primitive — buy-online-pickup-in-store workflow* — `bShop.clickAndCollect.create({ query?, order?, inventoryLocations?, notifications? })` returns `{ definePickupLocation, availableLocations, scheduleAtLocation, markReadyForPickup, markPickedUp, markNoShow, pickupsForLocation, customerSchedules }`. Five-state FSM: scheduled → ready → picked_up + no_show + cancelled terminals. Capacity gated on one-hour buckets; lead-time gate per location. `markReadyForPickup` enqueues a pickup-ready notification through the injected handle (drop-silent on failure so a notifications outage can't roll back the hold shelf). `markPickedUp` drives the parent order to delivered via `order.transition(id, 'mark_delivered')` (swallows fsm/illegal-transition for idempotency). Signature hashed via `namespaceHash('click-and-collect-signature', raw)`. Migration `0126_click_and_collect.sql`. · *`customerSurveys` primitive — post-purchase NPS / CSAT / CES surveys* — `bShop.customerSurveys.create({ query? })` returns `{ defineSurvey, getSurvey, archiveSurvey, issueInvitation, getInvitation, invitationsForCustomer, submitResponse, responsesForSurvey, rollup, closeInvitation, cleanupExpired }`. Kinds: nps / csat / ces / custom. Trigger events: after_delivery / after_support_close / after_refund / manual. NPS / CSAT / CES enforce primary-question shape (`max === 10 / 5 / 7` respectively). NPS rollup = `round(%promoters - %detractors)`, CSAT = top-2-box positive_pct + mean, CES = mean + agree_pct. Invitation tokens 32-byte base64url plaintext returned exactly once, stored only as SHA3-512 namespaceHash. Migration `0128_customer_surveys.sql`. · *`emailTemplates` primitive — operator-editable transactional email templates* — `bShop.emailTemplates.create({ query? })` returns `{ defineTemplate, getTemplate, listTemplates, updateTemplate, publishVersion, archiveTemplate, renderTemplate, versionsFor, validateVariables }`. Closed 11-kind enum (order_confirmation / order_shipped / order_delivered / order_refunded / password_reset / account_verification / abandoned_cart / wishlist_discount / review_request / welcome / generic). `{{var_name}}` substitution composes `b.template.escapeHtml`; `{{var_name|raw}}` slots require schema vouching at definition time. Locale fallback to `en`. Migration `0125_email_templates.sql`. · *`knowledgeBase` primitive — self-serve customer help center with search ranking* — `bShop.knowledgeBase.create({ query?, defaultLocale?, cursorSecret? })` returns `{ defineArticle, getArticle, listArticles, updateArticle, publishArticle, unpublishArticle, archiveArticle, recordView, recordVote, voteAggregateForArticle, popularArticles, searchSuggest }`. Locale fallback chain. `searchSuggest` ranks by title 3 + tag 2 + body 1 weighted score; archived + unpublished excluded. Vote dedup at the `UNIQUE(slug, session_id_hash)` index. In-process markdown subset composes `b.template.escapeHtml` + `b.safeUrl.parse` (https-only + /-rooted internal). Session ids hashed via `namespaceHash` under `kb-view-session` / `kb-vote-session`. Migration `0162_knowledge_base.sql`. · *`pixelEvents` primitive — server-side conversion-tracking pixel / event API* — `bShop.pixelEvents.create({ query? })` returns `{ registerProvider, recordEvent, dispatchTick, markDispatched, markFailed, eventsForOrder, dispatchedInPeriod, failedEvents, metricsForProvider }`. Provider enum: meta_capi / google_ec / tiktok_events / pinterest_capi / snap_capi. Event names: purchase / add_to_cart / view_content / lead / complete_registration / search. Customer email + phone SHA-256-hashed (per provider spec — NOT namespaceHash; ad platforms accept SHA-256 of the normalised value). Five-step retry back-off on transient failures. Migration `0123_pixel_events.sql`. · *`sitemapGenerator` primitive — storefront sitemap.xml + sitemap-index.xml* — `bShop.sitemapGenerator.create({ query?, catalog?, collections?, storefrontPages?, custom? })` returns `{ defineSection, sections, archiveSection, validateOriginUrl, generate, recordGeneration, lastGeneration }`. Splits at 50,000 URLs or 50 MB serialized per chunk (whichever hits first). Path percent-encoding + XML escape on every emitted `<loc>`. Per-section sources: product / collection / storefront_page / custom. `validateOriginUrl` gates via `b.safeUrl` (https-only). Migration `0130_sitemap_generator.sql`.
12
16
 
13
17
  - v0.0.69 (2026-05-22) — **Twelve new primitives + return-labels CI smoke fix.** Twelve primitives ship in one release plus a CI smoke fix that unblocks the npm publish workflow. The smoke fix bumps a too-tight 50ms `waitUntil` in `return-labels.test.js` to 5s so it survives the GitHub Actions runner's slower scheduling. New primitives cover orders (order ratings, packing slips, print queue), customers (wishlist sharing, customer activity feed, push notifications, order escalation), inventory (allocations, drop-ship forwarding, auto-replenishment), operator tooling (operator roles, clickstream events, damage photos). **Added:** *`orderRatings` primitive — per-order rating for shipping / packaging / recommend* — `bShop.orderRatings.create({ query? })` returns `{ submitRating, getRating, ratingsForCustomer, aggregateForPeriod, flagComment, responseToCustomer, topPositiveRatings, topNegativeRatings }`. Ratings 1..5 across three dimensions. UNIQUE(order_id) so one rating per order. Comment + operator response HTML-escaped on render. flagComment moderates abuse; responseToCustomer is the operator's public reply. Migration `0151_order_ratings.sql`. · *`wishlistSharing` primitive — share-a-wishlist links + group wishlists* — `bShop.wishlistSharing.create({ query?, wishlist? })` returns `{ createShareLink, revokeShareLink, viewShared, recordView, createGroupWishlist, joinGroupWishlist, leaveGroupWishlist, groupWishlistsForCustomer, listSharesForOwner, listGroupMembers }`. Tokens are 32-byte base64url plaintext returned once, hashed at rest via `namespaceHash` under per-domain namespaces. Privacy enum: public / unlisted / friends_only. Group wishlists let multiple customers co-author a list. Migration `0150_wishlist_sharing.sql`. · *`inventoryAllocations` primitive — soft-reserve holds for in-progress carts* — `bShop.inventoryAllocations.create({ query?, inventoryLocations? })` returns `{ holdForCart, releaseHold, releaseAllForCart, commitHold, extendHold, availableForSku, holdsForCart, cleanupExpiredHolds, metricsForSku }`. Holds prevent overselling during checkout. `availableForSku` returns committed - on_hold. `commitHold` composes `inventoryLocations.adjustStock` to commit the reservation as a real stock movement. TTL-bound; `cleanupExpiredHolds` walks expired rows and frees the inventory. Migration `0152_inventory_allocations.sql`. · *`dropshipForwarding` primitive — drop-ship vendor order-forwarding flow* — `bShop.dropshipForwarding.create({ query?, vendors?, orderTracking? })` returns `{ bindSkuToVendor, forwardOrder, markVendorAccepted, markVendorShipped, markVendorDelivered, markVendorFailed, markVendorReturned, getForwarding, forwardingsForOrder, pendingForwardings, metricsForVendor }`. Six-state FSM: queued / accepted / shipped / delivered / failed / returned. Composes `b.fsm` for transition validation. `forwardOrder` writes per-vendor forwarding rows. Migration `0154_dropship_forwarding.sql`. · *`damagePhotos` primitive — image attachments for damage / quality-control events* — `bShop.damagePhotos.create({ query? })` returns `{ recordPhoto, getPhoto, photosForSubject, archivePhoto, replacePhoto, metricsForKind, findDuplicatesBySha }`. Subject kinds: writeoff / return / quality_check / damaged_receipt / customer_complaint. SHA3-512 + size + content_type per upload. content_type allowlist: image/jpeg, image/png, image/webp, image/heic. `findDuplicatesBySha` returns prior uploads with the same digest (fraud detection signal). Migration `0159_damage_photos.sql`. · *`pushNotifications` primitive — APNs / FCM / Web Push outbound* — `bShop.pushNotifications.create({ query? })` returns `{ registerProvider, registerDevice, revokeDevice, recordOptIn, recordOptOut, isOptedIn, enqueueNotification, markDelivered, markFailed, dispatchTick, notificationsForCustomer, devicesForCustomer, metricsForProvider }`. Marketing channel uses opt-IN; transactional + alert use opt-OUT. Device class / provider kind compatibility enforced (ios↔apns, android↔fcm, web↔web_push, desktop↔fcm|web_push). Retry budget 5 with [1m, 5m, 30m, 2h, 12h] back-off. Migration `0148_push_notifications.sql`. · *`autoReplenish` primitive — auto-PO when reorder thresholds fire* — `bShop.autoReplenish.create({ query?, reorderThresholds?, purchaseOrders?, vendors? })` returns `{ definePolicy, tickReplenishment, getPolicy, listPolicies, archivePolicy, updatePolicy, replenishmentHistory, markPolicyTriggered }`. `tickReplenishment` finds reorder candidates via `reorderThresholds.scanAll` + groups by vendor + submits PO via `purchaseOrders.createDraft + submitToVendor`. Per-policy gates: min/max PO value, max_concurrent_open_pos cap, vendor-archived skip. Schedule: hourly / daily / weekly. Migration `0155_auto_replenish.sql`. · *`operatorRoles` primitive — staff-side RBAC* — `bShop.operatorRoles.create({ query?, operatorAuditLog? })` returns `{ defineRole, assignRoleToOperator, revokeRoleFromOperator, rolesForOperator, operatorsWithRole, hasPermission, listRoles, updateRole, archiveRole, listPermissions, permissionUsageLog, recordPermissionUse }`. 15-permission closed allow-list (orders.* / customers.* / catalog.* / inventory.write / vendors.manage / settings.write / billing.view / reports.read / users.* / support.handle). Multi-role union; expires_at honored. Migration `0157_operator_roles.sql`. · *`clickstream` primitive — server-side storefront analytics events* — `bShop.clickstream.create({ query? })` returns `{ recordPageView, recordEvent, sessionPath, topPages, topClicks, funnelAnalysis, bouncerate, dwellByPage, cleanupOlderThan }`. Tracks page views + clicks + form submits + scroll depth + dwell without third-party scripts. Event kinds: click / form_submit / form_abandon / video_play / video_complete / scroll_depth / dwell. Query strings + URL fragments stripped at write so PII in query params never reaches the table. Drop-silent on bad input. Migration `0161_clickstream.sql`. · *`customerActivity` primitive — per-customer aggregated activity timeline* — `bShop.customerActivity.create({ query?, cursorSecret?, order?, wishlist?, loyalty?, supportTickets?, reviews? })` returns `{ forCustomer, recentActivity, summarize, lastActivityAt, inactiveCustomers, purgeStaleCache }`. Aggregates events from order / wishlist / loyalty / support / reviews into one feed for the operator's customer-detail page. Missing peers skipped silently. `summarize` returns 30/90/365-day kind counts with cache hit/miss. Migration `0153_customer_activity_cache.sql`. · *`packingSlips` primitive — warehouse packing-slip rendering + print queue* — `bShop.packingSlips.create({ query?, order, giftOptions? })` returns `{ renderHtml, renderPdfPayload, recordPrint, printsForOrder, enqueueForLocation, dequeueForLocation, bulkRenderForLocation }`. Inlined Code-128 barcode SVG renderer (no external library dependency). HTML-escapes every operator + customer field. Gift options: hide_prices strips price/total columns; gift_message + recipient_name rendered inert against hostile input. Composite PK on the queue table as idempotency guard. Migration `0149_packing_slips.sql`. · *`printQueue` primitive — warehouse print job queue scheduler* — `bShop.printQueue.create({ query? })` returns `{ enqueueJob, claimJob, markComplete, markFailed, cancelJob, getJob, jobsForStation, pendingByKind, cleanupCompleted, stationActivity, dailyMetrics }`. Job kinds: packing_slip / shipping_label / invoice / packing_label / return_label / pickup_slip. `claimJob` returns next queued job (FIFO with priority tiebreak), flips `in_progress`. station_filter restricts which stations can claim a job. Retry budget on markFailed. Migration `0158_print_queue.sql`. **Fixed:** *`return-labels.test.js` — bumped a 50ms `waitUntil` timeout to 5s for CI runner scheduling* — GitHub Actions Ubuntu / macOS runners under contention sometimes need more than 50ms between `Date.now()` ticks to clear a monotonic-clock guard. The two `waitUntil` calls in `_listQueries` now use a 5000ms budget so the CI smoke gate doesn't drop the npm publish workflow. Local runs were always fast enough; only CI saw the failure.
package/lib/index.js CHANGED
@@ -200,4 +200,5 @@ module.exports = {
200
200
  knowledgeBase: require("./knowledge-base"),
201
201
  pixelEvents: require("./pixel-events"),
202
202
  sitemapGenerator: require("./sitemap-generator"),
203
+ loyaltyEarnRules: require("./loyalty-earn-rules"),
203
204
  };
@@ -0,0 +1,786 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.loyaltyEarnRules
4
+ * @title Loyalty earn rules — per-action point-earning configuration
5
+ *
6
+ * @intro
7
+ * Distinct from `loyalty` (which records the running points balance
8
+ * and the audited transaction trail) and from `tierBenefits` (which
9
+ * configures perks unlocked at each tier). This primitive defines
10
+ * HOW points are earned — operators publish rules keyed by an event
11
+ * `trigger` and the application calls `awardForEvent` at the
12
+ * appropriate lifecycle moment.
13
+ *
14
+ * Triggers (closed enum):
15
+ * - per_dollar_spent — N points per $1 of order subtotal
16
+ * - per_purchase — flat N points per completed order
17
+ * - per_review — N points per review submitted
18
+ * - per_referral_redeemed — N points per referred friend's
19
+ * first order completing
20
+ * - birthday — N points on the customer's birthday
21
+ * - signup_bonus — N points on account creation
22
+ * - first_purchase — N points on the first completed order
23
+ * - abandoned_cart_recovered — N points when a recovered cart converts
24
+ *
25
+ * Composition:
26
+ *
27
+ * var rules = bShop.loyaltyEarnRules.create({
28
+ * query: q,
29
+ * loyalty: loy, // optional — awardForEvent composes loy.earn
30
+ * });
31
+ *
32
+ * await rules.defineRule({
33
+ * slug: "spend-1pt-per-dollar",
34
+ * trigger: "per_dollar_spent",
35
+ * points_per_unit: 1,
36
+ * max_per_event: 5000,
37
+ * customer_status_in: ["active", "vip"],
38
+ * });
39
+ *
40
+ * await rules.awardForEvent({
41
+ * trigger: "per_dollar_spent",
42
+ * customer_id: customerId,
43
+ * dollars_spent: 42,
44
+ * trigger_event_ref: "order:" + orderId,
45
+ * customer_status: "active",
46
+ * });
47
+ *
48
+ * `evaluateForEvent` is the dry-run companion to `awardForEvent`. It
49
+ * returns the same `{ points, reason }` shape but does NOT touch the
50
+ * audit log or the loyalty ledger — operators preview an award at
51
+ * checkout (so the customer sees "you'll earn 42 points") without
52
+ * committing.
53
+ *
54
+ * `applyBatch` runs multiple awards in a single call. Each (rule,
55
+ * event) pair flows through the same validate -> evaluate -> award
56
+ * path; per-pair failures are collected into a `failed[]` array
57
+ * rather than failing the whole batch (operators commonly run nightly
58
+ * sweeps that span thousands of events — a malformed row shouldn't
59
+ * block the rest).
60
+ *
61
+ * Per-event dedup: the (rule_slug, customer_id, trigger_event_ref)
62
+ * UNIQUE on `loyalty_earn_log` collapses retried inserts onto one
63
+ * row so a webhook retry doesn't double-award.
64
+ *
65
+ * Composes:
66
+ * - `b.uuid.v7` — audit-log row ids (lexicographic + monotonic)
67
+ * - `b.guardUuid` — strict UUID gate on every customer_id
68
+ * - `loyalty` — optional; when wired, `awardForEvent` composes
69
+ * `loyalty.earn` so the points land in the
70
+ * customer's balance + the loyalty audit trail
71
+ * in one call.
72
+ *
73
+ * Storage: `migrations-d1/0163_loyalty_earn_rules.sql` —
74
+ * `loyalty_earn_rules` + `loyalty_earn_log`.
75
+ *
76
+ * @primitive loyaltyEarnRules
77
+ * @related loyalty, tierBenefits, b.uuid.v7, b.guardUuid
78
+ */
79
+
80
+ var bShop;
81
+ function _b() {
82
+ if (!bShop) bShop = require("./index");
83
+ return bShop.framework;
84
+ }
85
+
86
+ // ---- constants ----------------------------------------------------------
87
+
88
+ var TRIGGERS = Object.freeze([
89
+ "per_dollar_spent",
90
+ "per_purchase",
91
+ "per_review",
92
+ "per_referral_redeemed",
93
+ "birthday",
94
+ "signup_bonus",
95
+ "first_purchase",
96
+ "abandoned_cart_recovered",
97
+ ]);
98
+
99
+ // Triggers that scale points by a caller-supplied unit count.
100
+ // per_dollar_spent multiplies points_per_unit by the order's dollar
101
+ // subtotal; every other trigger awards a flat points_per_unit.
102
+ var UNIT_TRIGGERS = Object.freeze({
103
+ per_dollar_spent: "dollars_spent",
104
+ });
105
+
106
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/;
107
+ var TRIGGER_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
108
+ var STATUS_RE = /^[a-z][a-z0-9_-]{0,31}$/;
109
+
110
+ var MAX_STATUS_LIST = 16;
111
+ var MAX_POINTS_PER_UNIT = 1000000; // 1M cap on a single multiplier
112
+ var MAX_MAX_PER_EVENT = 1000000000; // 1B cap on a per-event ceiling
113
+
114
+ var DEFAULT_LIST_LIMIT = 50;
115
+ var MAX_LIST_LIMIT = 500;
116
+
117
+ // ---- monotonic clock ----------------------------------------------------
118
+ //
119
+ // Awards land in `loyalty_earn_log` keyed by `occurred_at`. The metrics
120
+ // rollup window scans by (rule_slug, occurred_at >= from AND <= to);
121
+ // two awards in the same millisecond would tie on the sort key and the
122
+ // `applyBatch` path issues many awards in a tight loop. The strict-
123
+ // monotonic clock guarantees distinct timestamps per call so ordering
124
+ // is deterministic without a tiebreaker column.
125
+
126
+ var _lastTs = 0;
127
+ function _now() {
128
+ var t = Date.now();
129
+ if (t <= _lastTs) { t = _lastTs + 1; }
130
+ _lastTs = t;
131
+ return t;
132
+ }
133
+
134
+ // ---- validators ---------------------------------------------------------
135
+
136
+ function _slug(s, label) {
137
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
138
+ throw new TypeError("loyaltyEarnRules: " + (label || "slug") +
139
+ " must be lowercase alnum + dash, no leading/trailing dash, 1..100 chars");
140
+ }
141
+ return s;
142
+ }
143
+
144
+ function _trigger(s) {
145
+ if (typeof s !== "string" || TRIGGERS.indexOf(s) < 0) {
146
+ throw new TypeError("loyaltyEarnRules: trigger must be one of " + TRIGGERS.join(", "));
147
+ }
148
+ return s;
149
+ }
150
+
151
+ function _pointsPerUnit(n) {
152
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_POINTS_PER_UNIT) {
153
+ throw new TypeError("loyaltyEarnRules: points_per_unit must be a positive integer <= " +
154
+ MAX_POINTS_PER_UNIT);
155
+ }
156
+ return n;
157
+ }
158
+
159
+ function _maxPerEvent(n) {
160
+ if (n == null) return null;
161
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_MAX_PER_EVENT) {
162
+ throw new TypeError("loyaltyEarnRules: max_per_event must be a positive integer <= " +
163
+ MAX_MAX_PER_EVENT + " (or omitted)");
164
+ }
165
+ return n;
166
+ }
167
+
168
+ function _customerStatusIn(arr) {
169
+ if (arr == null) return null;
170
+ if (!Array.isArray(arr)) {
171
+ throw new TypeError("loyaltyEarnRules: customer_status_in must be an array of status strings");
172
+ }
173
+ if (arr.length === 0) {
174
+ throw new TypeError("loyaltyEarnRules: customer_status_in must be non-empty when provided");
175
+ }
176
+ if (arr.length > MAX_STATUS_LIST) {
177
+ throw new TypeError("loyaltyEarnRules: customer_status_in must contain <= " +
178
+ MAX_STATUS_LIST + " entries");
179
+ }
180
+ var seen = Object.create(null);
181
+ var out = [];
182
+ for (var i = 0; i < arr.length; i += 1) {
183
+ var s = arr[i];
184
+ if (typeof s !== "string" || !STATUS_RE.test(s)) {
185
+ throw new TypeError("loyaltyEarnRules: customer_status_in[" + i +
186
+ "] must be lowercase alnum / underscore / dash, 1..32 chars");
187
+ }
188
+ if (seen[s]) {
189
+ throw new TypeError("loyaltyEarnRules: customer_status_in[" + i +
190
+ "] duplicates a previous entry");
191
+ }
192
+ seen[s] = true;
193
+ out.push(s);
194
+ }
195
+ return out;
196
+ }
197
+
198
+ function _uuid(s, label) {
199
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
200
+ catch (e) { throw new TypeError("loyaltyEarnRules: " + label + " — " + (e && e.message || "invalid UUID")); }
201
+ }
202
+
203
+ function _triggerEventRef(s) {
204
+ if (typeof s !== "string" || !TRIGGER_REF_RE.test(s)) {
205
+ throw new TypeError("loyaltyEarnRules: trigger_event_ref must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ (1..128 chars)");
206
+ }
207
+ return s;
208
+ }
209
+
210
+ function _statusOpt(s) {
211
+ if (s == null) return null;
212
+ if (typeof s !== "string" || !STATUS_RE.test(s)) {
213
+ throw new TypeError("loyaltyEarnRules: customer_status must be lowercase alnum / underscore / dash, 1..32 chars");
214
+ }
215
+ return s;
216
+ }
217
+
218
+ function _epochOpt(n, label) {
219
+ if (n == null) return null;
220
+ if (!Number.isInteger(n) || n < 0) {
221
+ throw new TypeError("loyaltyEarnRules: " + label + " must be a non-negative integer (ms epoch) or null");
222
+ }
223
+ return n;
224
+ }
225
+
226
+ function _limit(n) {
227
+ if (n == null) return DEFAULT_LIST_LIMIT;
228
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
229
+ throw new TypeError("loyaltyEarnRules: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
230
+ }
231
+ return n;
232
+ }
233
+
234
+ function _bool(v, label) {
235
+ if (typeof v !== "boolean") {
236
+ throw new TypeError("loyaltyEarnRules: " + label + " must be a boolean");
237
+ }
238
+ return v;
239
+ }
240
+
241
+ // Pure compute — given a rule row + an event context, return the
242
+ // (points, reason) tuple WITHOUT touching storage. Exported via
243
+ // evaluateForEvent + reused inside awardForEvent so the math is
244
+ // single-sourced.
245
+ function _computePoints(rule, ctx) {
246
+ var unitField = UNIT_TRIGGERS[rule.trigger];
247
+ var units = 1;
248
+ if (unitField != null) {
249
+ var raw = ctx[unitField];
250
+ if (typeof raw !== "number" || !isFinite(raw) || raw < 0) {
251
+ return { points: 0, reason: unitField + " must be a non-negative finite number for trigger " + rule.trigger };
252
+ }
253
+ units = Math.floor(raw);
254
+ if (units <= 0) {
255
+ return { points: 0, reason: unitField + " floored to zero" };
256
+ }
257
+ }
258
+ var raw_points = rule.points_per_unit * units;
259
+ if (rule.max_per_event != null && raw_points > rule.max_per_event) {
260
+ return {
261
+ points: rule.max_per_event,
262
+ reason: "capped at max_per_event=" + rule.max_per_event +
263
+ " (uncapped would have been " + raw_points + ")",
264
+ capped: true,
265
+ };
266
+ }
267
+ return { points: raw_points, reason: "trigger=" + rule.trigger + " units=" + units, capped: false };
268
+ }
269
+
270
+ // ---- factory ------------------------------------------------------------
271
+
272
+ function create(opts) {
273
+ opts = opts || {};
274
+ var query = opts.query;
275
+ if (!query) {
276
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
277
+ }
278
+ // Optional loyalty handle — when wired, awardForEvent calls
279
+ // loyalty.earn so the points land in the customer's balance + the
280
+ // loyalty transaction audit trail in one go. Absent, the primitive
281
+ // still writes the loyalty_earn_log breadcrumb but the operator is
282
+ // responsible for posting to the ledger separately.
283
+ var loyaltyHandle = opts.loyalty || null;
284
+
285
+ // ---- internal helpers -----------------------------------------------
286
+
287
+ function _decodeRule(row) {
288
+ if (!row) return null;
289
+ var statusList = null;
290
+ if (row.customer_status_in_json) {
291
+ try { statusList = JSON.parse(row.customer_status_in_json); }
292
+ catch (_e) { statusList = null; }
293
+ }
294
+ return {
295
+ slug: row.slug,
296
+ trigger: row.trigger,
297
+ points_per_unit: Number(row.points_per_unit),
298
+ max_per_event: row.max_per_event == null ? null : Number(row.max_per_event),
299
+ customer_status_in: statusList,
300
+ active: Number(row.active) === 1,
301
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
302
+ created_at: Number(row.created_at),
303
+ updated_at: Number(row.updated_at),
304
+ };
305
+ }
306
+
307
+ async function _ruleRow(slug) {
308
+ var r = await query("SELECT * FROM loyalty_earn_rules WHERE slug = ?1", [slug]);
309
+ return r.rows[0] || null;
310
+ }
311
+
312
+ // Status gate. If the rule restricts to a status list, the event's
313
+ // customer_status MUST appear in the list. NULL list means no
314
+ // restriction. Returns null when the event passes, or a reason
315
+ // string when it's filtered out.
316
+ function _statusFilter(rule, ctx) {
317
+ if (rule.customer_status_in == null) return null;
318
+ var status = ctx.customer_status;
319
+ if (status == null || rule.customer_status_in.indexOf(status) < 0) {
320
+ return "customer_status=" + JSON.stringify(status) +
321
+ " not in [" + rule.customer_status_in.join(", ") + "]";
322
+ }
323
+ return null;
324
+ }
325
+
326
+ // ---- defineRule -----------------------------------------------------
327
+
328
+ async function defineRule(input) {
329
+ if (!input || typeof input !== "object") {
330
+ throw new TypeError("loyaltyEarnRules.defineRule: input object required");
331
+ }
332
+ var slug = _slug(input.slug, "slug");
333
+ var trigger = _trigger(input.trigger);
334
+ var pointsPerUnit = _pointsPerUnit(input.points_per_unit);
335
+ var maxPerEvent = _maxPerEvent(input.max_per_event);
336
+ var customerStatusIn = _customerStatusIn(input.customer_status_in);
337
+ var active = input.active == null ? true : _bool(input.active, "active");
338
+
339
+ var existing = await _ruleRow(slug);
340
+ var ts = _now();
341
+ if (existing) {
342
+ if (existing.archived_at != null) {
343
+ throw new TypeError("loyaltyEarnRules.defineRule: rule " + JSON.stringify(slug) + " is archived");
344
+ }
345
+ await query(
346
+ "UPDATE loyalty_earn_rules " +
347
+ "SET trigger = ?1, points_per_unit = ?2, max_per_event = ?3, " +
348
+ "customer_status_in_json = ?4, active = ?5, updated_at = ?6 " +
349
+ "WHERE slug = ?7",
350
+ [trigger, pointsPerUnit, maxPerEvent,
351
+ customerStatusIn == null ? null : JSON.stringify(customerStatusIn),
352
+ active ? 1 : 0, ts, slug],
353
+ );
354
+ } else {
355
+ await query(
356
+ "INSERT INTO loyalty_earn_rules " +
357
+ "(slug, trigger, points_per_unit, max_per_event, customer_status_in_json, " +
358
+ " active, archived_at, created_at, updated_at) " +
359
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
360
+ [slug, trigger, pointsPerUnit, maxPerEvent,
361
+ customerStatusIn == null ? null : JSON.stringify(customerStatusIn),
362
+ active ? 1 : 0, ts],
363
+ );
364
+ }
365
+ return _decodeRule(await _ruleRow(slug));
366
+ }
367
+
368
+ // ---- getRule / listRules --------------------------------------------
369
+
370
+ async function getRule(slug) {
371
+ _slug(slug, "slug");
372
+ return _decodeRule(await _ruleRow(slug));
373
+ }
374
+
375
+ async function listRules(listOpts) {
376
+ listOpts = listOpts || {};
377
+ var activeOnly = listOpts.active_only == null ? false : _bool(listOpts.active_only, "active_only");
378
+ var limit = _limit(listOpts.limit);
379
+ var sql = "SELECT * FROM loyalty_earn_rules";
380
+ var params = [];
381
+ var idx = 1;
382
+ var where = [];
383
+ if (activeOnly) {
384
+ where.push("active = ?" + idx); params.push(1); idx += 1;
385
+ where.push("archived_at IS NULL");
386
+ }
387
+ if (listOpts.trigger != null) {
388
+ where.push("trigger = ?" + idx); params.push(_trigger(listOpts.trigger)); idx += 1;
389
+ }
390
+ if (where.length) sql += " WHERE " + where.join(" AND ");
391
+ sql += " ORDER BY slug ASC LIMIT ?" + idx;
392
+ params.push(limit);
393
+ var r = await query(sql, params);
394
+ var out = [];
395
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeRule(r.rows[i]));
396
+ return out;
397
+ }
398
+
399
+ // ---- updateRule -----------------------------------------------------
400
+
401
+ async function updateRule(slug, patch) {
402
+ _slug(slug, "slug");
403
+ if (!patch || typeof patch !== "object") {
404
+ throw new TypeError("loyaltyEarnRules.updateRule: patch object required");
405
+ }
406
+ var existing = await _ruleRow(slug);
407
+ if (!existing) return null;
408
+ if (existing.archived_at != null) {
409
+ throw new TypeError("loyaltyEarnRules.updateRule: rule " + JSON.stringify(slug) + " is archived");
410
+ }
411
+ var decoded = _decodeRule(existing);
412
+
413
+ var nextPoints = decoded.points_per_unit;
414
+ if (Object.prototype.hasOwnProperty.call(patch, "points_per_unit")) {
415
+ nextPoints = _pointsPerUnit(patch.points_per_unit);
416
+ }
417
+ var nextMax = decoded.max_per_event;
418
+ if (Object.prototype.hasOwnProperty.call(patch, "max_per_event")) {
419
+ nextMax = _maxPerEvent(patch.max_per_event);
420
+ }
421
+ var nextStatus = decoded.customer_status_in;
422
+ if (Object.prototype.hasOwnProperty.call(patch, "customer_status_in")) {
423
+ nextStatus = _customerStatusIn(patch.customer_status_in);
424
+ }
425
+ var nextActive = decoded.active;
426
+ if (Object.prototype.hasOwnProperty.call(patch, "active")) {
427
+ nextActive = _bool(patch.active, "active");
428
+ }
429
+ // trigger is immutable on update — operators that need a different
430
+ // trigger archive the rule and define a new one. Otherwise the
431
+ // metricsForRule history straddles two semantically distinct
432
+ // event spaces and the rollup becomes a lie.
433
+ if (Object.prototype.hasOwnProperty.call(patch, "trigger") && patch.trigger !== decoded.trigger) {
434
+ throw new TypeError("loyaltyEarnRules.updateRule: trigger is immutable — archive + define a new rule instead");
435
+ }
436
+
437
+ var ts = _now();
438
+ await query(
439
+ "UPDATE loyalty_earn_rules SET points_per_unit = ?1, max_per_event = ?2, " +
440
+ "customer_status_in_json = ?3, active = ?4, updated_at = ?5 WHERE slug = ?6",
441
+ [nextPoints, nextMax,
442
+ nextStatus == null ? null : JSON.stringify(nextStatus),
443
+ nextActive ? 1 : 0, ts, slug],
444
+ );
445
+ return _decodeRule(await _ruleRow(slug));
446
+ }
447
+
448
+ // ---- archiveRule ----------------------------------------------------
449
+
450
+ async function archiveRule(slug) {
451
+ _slug(slug, "slug");
452
+ var ts = _now();
453
+ var r = await query(
454
+ "UPDATE loyalty_earn_rules SET archived_at = ?1, active = 0, updated_at = ?1 " +
455
+ "WHERE slug = ?2 AND archived_at IS NULL",
456
+ [ts, slug],
457
+ );
458
+ if (Number(r.rowCount || 0) === 0) {
459
+ var existing = await _ruleRow(slug);
460
+ if (!existing) return null;
461
+ return _decodeRule(existing);
462
+ }
463
+ return _decodeRule(await _ruleRow(slug));
464
+ }
465
+
466
+ // ---- evaluateForEvent (dry-run) -------------------------------------
467
+
468
+ async function evaluateForEvent(input) {
469
+ if (!input || typeof input !== "object") {
470
+ throw new TypeError("loyaltyEarnRules.evaluateForEvent: input object required");
471
+ }
472
+ var trigger = _trigger(input.trigger);
473
+ _uuid(input.customer_id, "customer_id");
474
+ _statusOpt(input.customer_status);
475
+
476
+ // The slug-targeted path lets operators preview a single named
477
+ // rule even when several rules share the same trigger. Absent a
478
+ // slug, every active matching-trigger rule is evaluated; the
479
+ // primitive returns each rule's verdict (eligible / skipped /
480
+ // capped) so the operator-facing UI can render a per-rule
481
+ // breakdown.
482
+ var rules;
483
+ if (input.slug != null) {
484
+ var slug = _slug(input.slug, "slug");
485
+ var r = await _ruleRow(slug);
486
+ rules = r ? [r] : [];
487
+ } else {
488
+ var r2 = await query(
489
+ "SELECT * FROM loyalty_earn_rules WHERE trigger = ?1 AND active = 1 AND archived_at IS NULL " +
490
+ "ORDER BY slug ASC",
491
+ [trigger],
492
+ );
493
+ rules = r2.rows;
494
+ }
495
+
496
+ var verdicts = [];
497
+ var totalPoints = 0;
498
+ for (var i = 0; i < rules.length; i += 1) {
499
+ var rule = _decodeRule(rules[i]);
500
+ if (rule.trigger !== trigger) {
501
+ verdicts.push({ slug: rule.slug, eligible: false, points: 0,
502
+ reason: "rule.trigger=" + rule.trigger + " != requested " + trigger });
503
+ continue;
504
+ }
505
+ if (!rule.active || rule.archived_at != null) {
506
+ verdicts.push({ slug: rule.slug, eligible: false, points: 0,
507
+ reason: "rule is inactive or archived" });
508
+ continue;
509
+ }
510
+ var statusReason = _statusFilter(rule, input);
511
+ if (statusReason) {
512
+ verdicts.push({ slug: rule.slug, eligible: false, points: 0, reason: statusReason });
513
+ continue;
514
+ }
515
+ var calc = _computePoints(rule, input);
516
+ if (calc.points <= 0) {
517
+ verdicts.push({ slug: rule.slug, eligible: false, points: 0, reason: calc.reason });
518
+ continue;
519
+ }
520
+ verdicts.push({
521
+ slug: rule.slug,
522
+ eligible: true,
523
+ points: calc.points,
524
+ reason: calc.reason,
525
+ capped: !!calc.capped,
526
+ });
527
+ totalPoints += calc.points;
528
+ }
529
+
530
+ return {
531
+ trigger: trigger,
532
+ customer_id: input.customer_id,
533
+ total_points: totalPoints,
534
+ verdicts: verdicts,
535
+ };
536
+ }
537
+
538
+ // ---- awardForEvent --------------------------------------------------
539
+
540
+ async function awardForEvent(input) {
541
+ if (!input || typeof input !== "object") {
542
+ throw new TypeError("loyaltyEarnRules.awardForEvent: input object required");
543
+ }
544
+ var trigger = _trigger(input.trigger);
545
+ var customerId = _uuid(input.customer_id, "customer_id");
546
+ var triggerEventRef = _triggerEventRef(input.trigger_event_ref);
547
+ _statusOpt(input.customer_status);
548
+
549
+ var rules;
550
+ if (input.slug != null) {
551
+ var slug = _slug(input.slug, "slug");
552
+ var r = await _ruleRow(slug);
553
+ rules = r ? [r] : [];
554
+ } else {
555
+ var r2 = await query(
556
+ "SELECT * FROM loyalty_earn_rules WHERE trigger = ?1 AND active = 1 AND archived_at IS NULL " +
557
+ "ORDER BY slug ASC",
558
+ [trigger],
559
+ );
560
+ rules = r2.rows;
561
+ }
562
+
563
+ var awarded = [];
564
+ var skipped = [];
565
+ var totalPts = 0;
566
+ for (var i = 0; i < rules.length; i += 1) {
567
+ var rule = _decodeRule(rules[i]);
568
+ if (rule.trigger !== trigger) {
569
+ skipped.push({ slug: rule.slug, reason: "rule.trigger != requested trigger" });
570
+ continue;
571
+ }
572
+ if (!rule.active || rule.archived_at != null) {
573
+ skipped.push({ slug: rule.slug, reason: "rule is inactive or archived" });
574
+ continue;
575
+ }
576
+ var statusReason = _statusFilter(rule, input);
577
+ if (statusReason) {
578
+ skipped.push({ slug: rule.slug, reason: statusReason });
579
+ continue;
580
+ }
581
+ var calc = _computePoints(rule, input);
582
+ if (calc.points <= 0) {
583
+ skipped.push({ slug: rule.slug, reason: calc.reason });
584
+ continue;
585
+ }
586
+
587
+ // Dedup at the storage layer: the UNIQUE (rule_slug,
588
+ // customer_id, trigger_event_ref) collapses a retried award
589
+ // onto the existing row. SQLite's INSERT OR IGNORE is the
590
+ // cheapest portable shape — when 0 rows change we surface the
591
+ // dedup as a skipped reason rather than a hard error so a
592
+ // webhook retry produces a consistent observable result.
593
+ var logId = _b().uuid.v7();
594
+ var ts = _now();
595
+ var ins = await query(
596
+ "INSERT OR IGNORE INTO loyalty_earn_log " +
597
+ "(id, rule_slug, customer_id, points_awarded, trigger_event_ref, occurred_at) " +
598
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
599
+ [logId, rule.slug, customerId, calc.points, triggerEventRef, ts],
600
+ );
601
+ if (Number(ins.rowCount || 0) === 0) {
602
+ skipped.push({
603
+ slug: rule.slug,
604
+ reason: "duplicate trigger_event_ref — already awarded for this event",
605
+ });
606
+ continue;
607
+ }
608
+
609
+ // Compose loyalty.earn when wired. Source is derived from the
610
+ // trigger name — loyalty's source validator demands lowercase
611
+ // alnum + `._-`, which the trigger enum already satisfies.
612
+ if (loyaltyHandle && typeof loyaltyHandle.earn === "function") {
613
+ try {
614
+ await loyaltyHandle.earn({
615
+ customer_id: customerId,
616
+ points: calc.points,
617
+ source: "earn-rule." + rule.slug,
618
+ notes: "trigger=" + trigger + " ref=" + triggerEventRef,
619
+ });
620
+ } catch (err) {
621
+ // Loyalty ledger failed AFTER the audit log wrote. Roll
622
+ // the audit row back so the next retry isn't dedup-skipped
623
+ // against a row that never made it to the ledger. The
624
+ // operator-facing failure carries the underlying loyalty
625
+ // error so debugging hits the root cause not the audit
626
+ // breadcrumb.
627
+ await query(
628
+ "DELETE FROM loyalty_earn_log WHERE id = ?1",
629
+ [logId],
630
+ );
631
+ throw err;
632
+ }
633
+ }
634
+
635
+ awarded.push({
636
+ log_id: logId,
637
+ slug: rule.slug,
638
+ points: calc.points,
639
+ capped: !!calc.capped,
640
+ trigger_event_ref: triggerEventRef,
641
+ occurred_at: ts,
642
+ });
643
+ totalPts += calc.points;
644
+ }
645
+
646
+ return {
647
+ trigger: trigger,
648
+ customer_id: customerId,
649
+ total_points: totalPts,
650
+ awarded: awarded,
651
+ skipped: skipped,
652
+ };
653
+ }
654
+
655
+ // ---- metricsForRule -------------------------------------------------
656
+
657
+ async function metricsForRule(input) {
658
+ if (!input || typeof input !== "object") {
659
+ throw new TypeError("loyaltyEarnRules.metricsForRule: input object required");
660
+ }
661
+ var slug = _slug(input.slug, "slug");
662
+ var from = _epochOpt(input.from, "from");
663
+ var to = _epochOpt(input.to, "to");
664
+ if (from != null && to != null && from > to) {
665
+ throw new TypeError("loyaltyEarnRules.metricsForRule: from must be <= to");
666
+ }
667
+ var ruleRow = await _ruleRow(slug);
668
+ if (!ruleRow) return null;
669
+
670
+ var sql = "SELECT COUNT(*) AS award_count, COUNT(DISTINCT customer_id) AS unique_customers, " +
671
+ "COALESCE(SUM(points_awarded), 0) AS total_points, " +
672
+ "MIN(occurred_at) AS first_award, MAX(occurred_at) AS last_award " +
673
+ "FROM loyalty_earn_log WHERE rule_slug = ?1";
674
+ var params = [slug];
675
+ var idx = 2;
676
+ if (from != null) { sql += " AND occurred_at >= ?" + idx; params.push(from); idx += 1; }
677
+ if (to != null) { sql += " AND occurred_at <= ?" + idx; params.push(to); idx += 1; }
678
+
679
+ var r = await query(sql, params);
680
+ var row = r.rows[0] || { award_count: 0, unique_customers: 0, total_points: 0,
681
+ first_award: null, last_award: null };
682
+ return {
683
+ slug: slug,
684
+ trigger: ruleRow.trigger,
685
+ from: from,
686
+ to: to,
687
+ award_count: Number(row.award_count || 0),
688
+ unique_customers: Number(row.unique_customers || 0),
689
+ total_points: Number(row.total_points || 0),
690
+ first_award: row.first_award == null ? null : Number(row.first_award),
691
+ last_award: row.last_award == null ? null : Number(row.last_award),
692
+ };
693
+ }
694
+
695
+ // ---- applyBatch -----------------------------------------------------
696
+
697
+ async function applyBatch(input) {
698
+ if (!input || typeof input !== "object") {
699
+ throw new TypeError("loyaltyEarnRules.applyBatch: input object required");
700
+ }
701
+ if (!Array.isArray(input.events) || input.events.length === 0) {
702
+ throw new TypeError("loyaltyEarnRules.applyBatch: events must be a non-empty array");
703
+ }
704
+ if (input.events.length > 10000) {
705
+ throw new TypeError("loyaltyEarnRules.applyBatch: events.length must be <= 10000");
706
+ }
707
+ // `rules` is an optional advisory hint — when supplied, the batch
708
+ // restricts to those rule slugs by passing through the per-event
709
+ // `slug` field. The primary path uses the per-event `slug` (when
710
+ // present) or `trigger` (when absent).
711
+ var ruleFilter = null;
712
+ if (input.rules != null) {
713
+ if (!Array.isArray(input.rules)) {
714
+ throw new TypeError("loyaltyEarnRules.applyBatch: rules must be an array of slugs when provided");
715
+ }
716
+ ruleFilter = Object.create(null);
717
+ for (var ri = 0; ri < input.rules.length; ri += 1) {
718
+ ruleFilter[_slug(input.rules[ri], "rules[" + ri + "]")] = true;
719
+ }
720
+ }
721
+
722
+ var awarded = [];
723
+ var skipped = [];
724
+ var failed = [];
725
+ var totalPts = 0;
726
+
727
+ for (var i = 0; i < input.events.length; i += 1) {
728
+ var ev = input.events[i];
729
+ if (!ev || typeof ev !== "object") {
730
+ failed.push({ index: i, reason: "event must be an object" });
731
+ continue;
732
+ }
733
+ if (ruleFilter != null && ev.slug != null && !ruleFilter[ev.slug]) {
734
+ skipped.push({ index: i, slug: ev.slug, reason: "slug not in rules filter" });
735
+ continue;
736
+ }
737
+ try {
738
+ var result = await awardForEvent(ev);
739
+ for (var a = 0; a < result.awarded.length; a += 1) {
740
+ awarded.push({ index: i, award: result.awarded[a] });
741
+ totalPts += result.awarded[a].points;
742
+ }
743
+ for (var s = 0; s < result.skipped.length; s += 1) {
744
+ skipped.push({ index: i, slug: result.skipped[s].slug, reason: result.skipped[s].reason });
745
+ }
746
+ } catch (err) {
747
+ failed.push({ index: i, reason: err && err.message ? err.message : String(err) });
748
+ }
749
+ }
750
+
751
+ return {
752
+ total_events: input.events.length,
753
+ total_points: totalPts,
754
+ awarded: awarded,
755
+ skipped: skipped,
756
+ failed: failed,
757
+ };
758
+ }
759
+
760
+ return {
761
+ TRIGGERS: TRIGGERS.slice(),
762
+ UNIT_TRIGGERS: Object.assign({}, UNIT_TRIGGERS),
763
+ MAX_POINTS_PER_UNIT: MAX_POINTS_PER_UNIT,
764
+ MAX_MAX_PER_EVENT: MAX_MAX_PER_EVENT,
765
+ MAX_STATUS_LIST: MAX_STATUS_LIST,
766
+
767
+ defineRule: defineRule,
768
+ getRule: getRule,
769
+ listRules: listRules,
770
+ updateRule: updateRule,
771
+ archiveRule: archiveRule,
772
+ evaluateForEvent: evaluateForEvent,
773
+ awardForEvent: awardForEvent,
774
+ metricsForRule: metricsForRule,
775
+ applyBatch: applyBatch,
776
+ };
777
+ }
778
+
779
+ module.exports = {
780
+ create: create,
781
+ TRIGGERS: TRIGGERS,
782
+ UNIT_TRIGGERS: UNIT_TRIGGERS,
783
+ MAX_POINTS_PER_UNIT: MAX_POINTS_PER_UNIT,
784
+ MAX_MAX_PER_EVENT: MAX_MAX_PER_EVENT,
785
+ MAX_STATUS_LIST: MAX_STATUS_LIST,
786
+ };
@@ -143,7 +143,13 @@ function _shortText(s, label, max) {
143
143
  return s;
144
144
  }
145
145
 
146
- function _now() { return Date.now(); }
146
+ var _lastTs = 0;
147
+ function _now() {
148
+ var t = Date.now();
149
+ if (t <= _lastTs) { t = _lastTs + 1; }
150
+ _lastTs = t;
151
+ return t;
152
+ }
147
153
 
148
154
  // ---- factory ------------------------------------------------------------
149
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.0.70",
3
+ "version": "0.0.72",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {