@blamejs/blamejs-shop 0.0.62 → 0.0.65
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 +6 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/compliance-export.js +614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/error-log.js +525 -0
- package/lib/index.js +25 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/store-credit.js +565 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.65 (2026-05-22) — **Twenty new primitives: address validation, auto discount, captcha gate, catalog drafts, cookie consent, customer roles, cycle counting, delivery estimate, email warmup, metered usage, price display, product bulk ops, purchase orders, quotes, recommendations, reorder thresholds, shipping zones, split shipments, trust badges, webhook receiver.** Twenty primitives ship in one release. Covers per-customer self-serve flows (cookie consent, captcha gate, address validation), operator-side catalog tooling (drafts, bulk ops, reorder thresholds, purchase orders, cycle counting, delivery estimate, shipping zones), commerce primitives (auto discount, quotes, recommendations, price display, split shipments, metered usage, trust badges), and integrations (webhook receiver, email warmup, customer roles). **Added:** *`addressValidation` primitive — cache + lookup for address-validation API results* — `bShop.addressValidation.create({ query? })` returns `{ recordValidation, lookupCached, signatureFor, recordSuggestion, lookupSuggestions, cleanupExpired, metricsForSource, validationsForOrder }`. Input signature derived via `namespaceHash('address-validation-input', canonical(input))` so the same address normalizes to one cache row. Source enum: `usps / smarty / lob / google / melissa / manual`. Classification enum: `residential / commercial / po_box / military / unknown`. Migration `0115_address_validation.sql`. · *`autoDiscount` primitive — automatic discounts without coupon codes* — `bShop.autoDiscount.create({ query?, catalog?, customerSegments? })` returns `{ defineRule, getRule, listRules, updateRule, archiveRule, evaluate, recordApplication, metricsForRule }`. Trigger kinds: `cart_total_min / item_count_min / sku_purchase`. Value kinds: `percent_off / amount_off_total / amount_off_each / free_shipping / bogo`. Priority + max_redemptions + customer segment gating. Migration `0107_auto_discount.sql`. · *`captchaGate` primitive — CAPTCHA verification at high-risk entrypoints* — `bShop.captchaGate.create({ query? })` returns `{ registerProvider, verifyToken, recordOutcome, metricsForProvider, ... }`. Provider enum: `turnstile / hcaptcha / recaptcha_v2 / recaptcha_v3`. reCAPTCHA v3 gets a threshold-score floor (basis-points). Secret hashed via `namespaceHash('captcha-secret', ...)`; session id hashed before write. Provider-callback-driven verify; operator's worker calls the actual provider endpoint. Migration `0114_captcha_gate.sql`. · *`catalogDrafts` primitive — staging workflow for catalog mutations* — `bShop.catalogDrafts.create({ query?, catalog })` returns `{ openDraft, stageChange, listChanges, removeChange, previewMerged, publishDraft, cancelDraft, rollbackDraft, listDrafts, historyForSku }`. Stage N changes against catalog (create / update / archive products + variants + prices + tags + inventory); publish atomically. 7-day rollback window. Pre-flight validates every change against the live catalog before writing anything. Migration `0112_catalog_drafts.sql`. · *`cookieConsent` primitive — GDPR / ePrivacy consent management* — `bShop.cookieConsent.create({ query? })` returns `{ recordConsent, getConsentFor, withdrawConsent, categoryAllowed, metricsForBanner, cleanupOlderThan, registerPolicyVersion }`. Categories: strictly-necessary (always on) + functional + analytics + marketing + preferences. DNT / Sec-GPC headers force implicit deny on marketing + analytics regardless of recorded consent. Policy-version bump flags older session consents for re-prompt. Session id hashed before write. Migration `0103_cookie_consent.sql`. · *`customerRoles` primitive — B2B company-account roles + capabilities* — `bShop.customerRoles.create({ query?, customers? })` returns `{ defineRole, assignRole, unassignRole, rolesForEmployee, employeesForCompany, hasCapability, recordOrderApproval, listRoles, updateRole, archiveRole }`. Capabilities: `can_view_orders / can_place_order / can_approve_order / can_manage_users / can_view_pricing / can_apply_payment_terms / can_request_quote / can_view_invoices`. UNIQUE(company, employee) so an employee carries one role per company. Migration `0101_customer_roles.sql`. · *`cycleCounting` primitive — physical inventory cycle counts* — `bShop.cycleCounting.create({ query?, catalog, inventoryLocations? })` returns `{ defineCount, worksheetFor, recordCount, finalizeCount, cancelCount, discrepanciesFor, listCounts, historyForSku, getCount }`. Kinds: `rotating / abc / full`. `finalizeCount({ apply_adjustments })` writes adjustments through `inventoryLocations.adjustStock` with `reason='cycle-count:<slug>'` so the audit row carries the count slug. Migration `0108_cycle_counting.sql`. · *`deliveryEstimate` primitive — PDP + cart delivery-window calculator* — `bShop.deliveryEstimate.create({ query?, inventoryLocations?, shippingZones? })` returns `{ defineCarrierTransit, defineCutoff, defineHoliday, definePostalZone, estimate, estimateForCart, listTransits, listCutoffs, listHolidays, archiveTransit, archiveHoliday }`. Calendar math is Intl-DateTimeFormat-driven (DST-correct); weekend + region-holiday skips applied separately to origin ship-by + destination delivery-by. Migration `0117_delivery_estimate.sql`. · *`emailWarmup` primitive — gradual SMTP IP/domain warmup* — `bShop.emailWarmup.create({ query?, now? })` returns `{ defineSchedule, canSend, recordSends, recordEngagement, currentDay, pauseSchedule, resumeSchedule, archiveSchedule, listSchedules, getSchedule, metricsForSchedule }`. Daily targets array (Day 1: 50, Day 2: 100, etc.) cap outbound volume to build sender reputation. Day-index math computed against UTC midnight of `start_date`. Migration `0116_email_warmup.sql`. · *`meteredUsage` primitive — usage-based subscription billing* — `bShop.meteredUsage.create({ query?, subscriptions?, subscriptionBilling? })` returns `{ defineMeter, recordUsage, usageForPeriod, periodSummary, recordPeriodInvoice, listMeters, updateMeter, archiveMeter }`. Tier schedule for tiered pricing (first 1000 free, next 10000 at $0.001, ...). `recordUsage` idempotency_key dedups re-submitted events. `recordPeriodInvoice` queues invoice line via subscriptionBilling. Migration `0095_metered_usage.sql`. · *`priceDisplay` primitive — per-region tax-included / tax-excluded display modes* — `bShop.priceDisplay.create({ query?, tax?, taxRates?, geolocation? })` returns `{ defineRule, getModeFor, formatPrice, defineCustomerOverride, clearCustomerOverride, customerOverride, listRules, updateRule, archiveRule }`. Resolution priority: customer-override > customer_status_in > country default. Compose `tax.calculateInclusive / calculateExclusive` for the breakdown. Migration `0097_price_display.sql`. · *`productBulkOps` primitive — bulk product mutations with pre-flight + audit* — `bShop.productBulkOps.create({ query?, catalog, maxBulkRows? })` returns `{ bulkSetPrice, bulkAdjustPrice, bulkArchive, bulkUnarchive, bulkAddTag, bulkRemoveTag, bulkSetInventory, previewFilter, auditTrail, categories, tags }`. Filter `{ skus?, vendor_slug?, category?, tag_any?, tag_all? }`. Pre-flight cap default 1000, ceiling 10000. Half-up rounding on percent adjustments; floored at 0. Process-local monotonic `_now` so back-to-back audit rows sort newest-first reliably. Migration `0104_product_bulk_ops.sql`. · *`purchaseOrders` primitive — operator-facing POs to vendors* — `bShop.purchaseOrders.create({ query?, vendors?, inventoryReceive? })` returns `{ createDraft, submitToVendor, confirmByVendor, recordPartialReceipt, closePO, cancelPO, getPO, listPOs, linesForPO, update }`. FSM `draft → submitted → confirmed → partially_received → received → closed`. `recordPartialReceipt` composes `inventoryReceive.draft + apply` for restock. `closePO` refuses unless all lines fully received. Migration `0099_purchase_orders.sql`. · *`quotes` primitive — B2B RFQ / quote flow* — `bShop.quotes.create({ query?, cart?, order? })` returns `{ requestQuote, respondToQuote, customerAccept, customerReject, cancelQuote, convertToOrder, getQuote, quotesForCustomer, pendingResponse, listExpired, markExpired }`. FSM `requested → responded → accepted → converted` with `rejected / expired / cancelled` terminal branches. `convertToOrder` composes `order.createFromCart` using the quoted prices. Accepts either `lines` or `cart_id` (via cart aggregator). Migration `0102_quotes.sql`. · *`recommendations` primitive — storefront product recommendation engine* — `bShop.recommendations.create({ query?, catalog, analytics?, recentlyViewed? })` returns `{ recommendForProduct, recommendForCart, recommendForCustomer, recommendForCategory, setOverride, removeOverride, listOverrides, recordImpression, recordClick, recordConversion, metricsForKind }`. Override layer reads first then falls through co-purchase → category-popular → random-in-stock. Session id namespace-hashed before write. Migration `0105_recommendations.sql`. · *`reorderThresholds` primitive — per-SKU + per-location reorder thresholds + PO suggestions* — `bShop.reorderThresholds.create({ query?, catalog, inventoryLocations?, vendors? })` returns `{ defineThreshold, evaluate, scanAll, proposePurchaseOrder, recordVelocity, updateThreshold, archiveThreshold, listThresholds }`. Velocity-driven suggested_qty = `gap + ceil(units_per_day * lead_time_days)`. Partial-UNIQUE on `(sku, location_code)` for active rows. Migration `0098_reorder_thresholds.sql`. · *`shippingZones` primitive — operator-defined shipping zones + rate tables* — `bShop.shippingZones.create({ query? })` returns `{ defineZone, getZone, listZones, updateZone, archiveZone, rateFor, zoneForDestination }`. Region match: country-only or country + region. `rateFor` half-open `[min_weight, max_weight)` + `[min_order, max_order)` bucketing across rate rows; returns all matches sorted by rate ascending. Migration `0106_shipping_zones.sql`. · *`splitShipments` primitive — one-order N-shipment planning* — `bShop.splitShipments.create({ query?, order, orderTracking, backorder?, inventoryLocations? })` returns `{ planSplit, executeSplit, mergeShipments, splitsForOrder, recommendStrategy }`. Strategies: `availability / location / vendor / manual`. `executeSplit` writes shipment rows via `orderTracking.createShipment`. `mergeShipments` combines source shipments into a target. Migration `0096_split_shipments.sql`. · *`trustBadges` primitive — storefront trust seals + certifications* — `bShop.trustBadges.create({ query? })` returns `{ defineBadge, activeForPlacement, listAll, getBadge, updateBadge, archiveBadge, renderHtml, recordImpression, recordClick, impressionCount, clickCount }`. Placements: header / footer / pdp / cart_review / checkout / order_confirmation. SVG payload sanitized via `b.guardSvg`; link_url through `b.safeUrl`. Multiple badges can stack at one placement, sorted priority-DESC. Migration `0111_trust_badges.sql`. · *`webhookReceiver` primitive — inbound HMAC-signed webhook acceptor* — `bShop.webhookReceiver.create({ query?, now? })` returns `{ defineSource, verifyAndPersist, markProcessed, markFailed, unprocessedEvents, eventsForSource, getEvent, purgeOlderThan, rotateSecret, archiveSource, getSource, listSources }`. HMAC-SHA-256 verification + replay-window timestamps + idempotency_key dedup. Rotation carries a 24h grace window where deliveries signed under the old secret still verify. Plaintext secret returned once at `defineSource` / `rotateSecret`; storage holds only the namespaceHash digest. Migration `0110_webhook_receiver.sql`. **Fixed:** *`productBulkOps` — monotonic `_now` so back-to-back audit rows sort newest-first reliably* — Same Date.now() resolution issue that affected several earlier primitives — replaced with a process-local monotonic clock that bumps by 1ms on collision. The audit-trail `ORDER BY occurred_at DESC, id DESC` listing now returns rows in the correct insertion order even under same-millisecond bulk operations.
|
|
12
|
+
|
|
13
|
+
- v0.0.64 (2026-05-22) — **Two new primitives: compliance export + live chat.** `complianceExport` is the GDPR / CCPA / LGPD subject-access-request export + deletion lifecycle. `liveChat` is the real-time customer-service queue with operator assignment and per-session messages. **Added:** *`complianceExport` primitive — GDPR / CCPA / LGPD subject-access-request export + deletion lifecycle* — `bShop.complianceExport.create({ query?, customers, order?, orderNotes?, subscriptions?, addresses?, paymentMethods?, supportTickets?, loyalty? })` returns `{ requestExport, requestDeletion, getRequest, listRequests, fulfillRequest, dispatchExport, processDeletion, dismissRequest, auditForCustomer }`. Composes injected per-domain readers via a `forCustomerExport(customer_id)` / `forCustomerDeletion(customer_id, { dry_run })` contract; the primitive owns the request row, the scope/section filtering, and the dry-run-vs-wet posture. Scope enum: `full / orders_only / identity_only`. Jurisdiction enum: `gdpr / ccpa / lgpd / other`. Status FSM: `received / processing / fulfilled / delivered / dismissed`. Migration `0109_compliance_export.sql`. · *`liveChat` primitive — real-time customer-service queue* — `bShop.liveChat.create({ query? })` returns `{ openSession, enqueueSession, assignToOperator, recordMessage, closeSession, getSession, messagesForSession, operatorQueue, waitingQueue, operatorWorkload, markOperatorAvailable, markOperatorAway, cleanupAbandoned }`. Six-state FSM: queued / assigned / active / waiting / closed / abandoned. Visitor session id + email hashed via `namespaceHash` under `live-chat-visitor` / `live-chat-email` namespaces; raw values never persist. Process-local monotonic `_now` so back-to-back operator assignments and message inserts paginate stably. `cleanupAbandoned({ idle_minutes })` reaps idle sessions. Migration `0113_live_chat.sql`.
|
|
14
|
+
|
|
15
|
+
- v0.0.63 (2026-05-22) — **Three new primitives: invoice renderer, store credit, error log.** Three primitives ship in this release. `invoiceRenderer` produces operator + customer-facing formal invoices (HTML for PDF conversion + plain text + sequential invoice numbers). `storeCredit` is the per-customer account-bound credit wallet, distinct from gift cards (no code). `errorLog` is the operator-side HTTP error event log with top-404 / top-5xx / slow-render dashboards. **Added:** *`invoiceRenderer` primitive — formal accounting invoices with sequential numbers* — `bShop.invoiceRenderer.create({ query?, order })` returns `{ nextInvoiceNumber, renderHtml, recordInvoice, invoicesForOrder, getInvoiceByNumber }`. `nextInvoiceNumber({ series? })` returns sequential `INV-YYYY-NNNNNN`-shaped id with per-series counter. `renderHtml({ order_id, locale?, series?, due_days? })` returns the full HTML invoice with operator + customer addresses, line items, tax breakdown, totals, payment terms, due date. All operator + customer input fields HTML-escaped via `b.template.escapeHtml`. `recordInvoice` writes the audit row carrying `html_size` + `sha3_512` digest. Migration `0093_invoice_renderer.sql` (`invoice_renderings` + `invoice_sequence` tables). · *`storeCredit` primitive — per-customer account-bound credit wallet* — `bShop.storeCredit.create({ query? })` returns `{ credit, debit, expire, balance, history, transactionsForOrder, expiringWithin, bulkBalance, cleanupExpired }`. Distinct from `giftCardLedger` — store credit is account-bound, no code. `credit` source enum: `refund / goodwill / promotional / manual / loyalty_redemption`. `debit` refuses overdraft. `balance_after_minor` denormalized per row so current balance is O(1). `cleanupExpired({ now })` scheduler-callable: walks expired rows + adds offsetting expire entries. `expiringWithin({ customer_id, days })` lists balance set to expire so the storefront can prompt the customer. Migration `0094_store_credit.sql`. · *`errorLog` primitive — HTTP error event log with operator dashboards* — `bShop.errorLog.create({ query? })` returns `{ recordError, top404Paths, top5xxErrors, slowRenders, errorById, byStatus, metrics }`. Drop-silent on bad input — hot-path observability sink; throwing here would crash the request that triggered the log entry. Session id always hashed via `namespaceHash('error-log-session', ...)` before write. `top404Paths({ from, to, limit })` and `top5xxErrors({ from, to, limit })` power operator dead-link cleanup + upstream-health dashboards. `slowRenders({ from, to, limit, p99_threshold_ms? })` surfaces requests over the operator-configured p99 budget. `metrics` returns counts + p50 / p95 / p99 response time percentiles. Migration `0100_error_log.sql`.
|
|
16
|
+
|
|
11
17
|
- v0.0.62 (2026-05-22) — **Ten new primitives: plan changes, vendors, loyalty redemption, gift registry, geolocation, stock transfers, SMS dispatcher, email campaigns, storefront dashboards, refund policy.** Ten primitives ship in one release covering subscription proration, multi-vendor marketplace foundation, loyalty redemption catalog, wedding/baby/housewarming registries, country resolution from request hints, audited multi-step stock movements, SMS opt-in / dispatch / STOP-keyword handling, bulk email campaign scheduling, operator dashboard composition, and refund eligibility rules. **Added:** *`planChanges` primitive — subscription plan upgrade / downgrade with proration* — `bShop.planChanges.create({ query?, subscriptions, subscriptionBilling? })` returns `{ proposeChange, executeChange, cancelPendingChange, pendingChangeFor, historyForSubscription, applyScheduledChanges, prorate }`. `proposeChange({ subscription_id, new_plan_id, change_at? })` returns `{ proration_credit_minor, first_charge_minor, currency, effective_at, change_kind, from_plan_id, to_plan_id }`. `change_kind` derives off the clock: `change_at >= current_period_end` → `next_billing_cycle` with zero proration; otherwise → `immediate` with floored `(amount * remainingMs / periodMs)` math on both sides. `executeChange` writes the ledger row, flips `subscriptions.plan_id` when the effective clock is now-or-past or queues for the scheduler. Queues a net proration invoice through `subscriptionBilling.recordInvoice` when injected. Module-level `planChanges.prorate(fromAmount, toAmount, periodStart, periodEnd, effectiveAt)` is exposed for callers that want the pure math without instantiating the factory. Migration `0083_plan_changes.sql`. · *`vendors` primitive — multi-vendor marketplace foundation* — `bShop.vendors.create({ query? })` returns `{ registerVendor, getVendor, vendorBySlug, listVendors, updateVendor, pauseVendor, reinstateVendor, archiveVendor, assignSku, unassignSku, vendorForSku, skusForVendor, recordCommission, payoutsDue }`. Slug is the public PK, immutable post-registration so `vendor_skus` + `vendor_commissions` rows never orphan. `contact_email` hashed via `namespaceHash('vendor-contact-email', lowercased)` — raw email never persists. FSM: `active ↔ paused`, both → `archived` (terminal). `assignSku` enforces single-vendor SKU ownership via `vendor_skus.sku UNIQUE`. `recordCommission` is idempotent on `(vendor_slug, order_id)` so the same multi-vendor `order_id` across different vendors is legal. Commission math is `floor(gross_minor * split_bps / 10000)`. Migration `0084_vendors.sql`. · *`loyaltyRedemption` primitive — customer-facing reward redemption layer* — `bShop.loyaltyRedemption.create({ query?, loyalty, coupons? })` returns `{ defineReward, getReward, listRewards, updateReward, archiveReward, redeemForCustomer, getRedemption, redemptionsForCustomer, markConsumed, cancelRedemption }`. Reward kinds: `discount_percent / discount_amount / free_product / free_shipping`. `redeemForCustomer` debits points via `loyalty.redeem`, mints a single-use coupon via `coupons` if injected. `max_per_customer` cap enforced across active+consumed; cancelled redemptions free a slot. `cancelRedemption` refunds points only when status is `active` at cancel time — points consumed past the FSM state are operator-only via `loyalty.adjust`. Migration `0085_loyalty_redemptions.sql`. · *`giftRegistry` primitive — wedding / baby / housewarming registries* — `bShop.giftRegistry.create({ query?, catalog })` returns `{ createRegistry, addItem, removeItem, getRegistry, getBySlug, listForOwner, searchPublic, purchaseItem, progressFor, closeRegistry, update }`. Privacy enum: `private / unlisted / public`. Owner-facing purchase reads redact `buyer_customer_id` when `reveal_buyer=false`; storage still carries the id for refund/audit. `active → closed` FSM gates all mutation paths. Over-purchase past `quantity_desired` throws `GIFT_REGISTRY_OVER_PURCHASE`. `progressFor(slug)` returns per-item + overall progress percentages. Migration `0086_gift_registry.sql`. · *`geolocation` primitive — country / locale resolution from request hints* — `bShop.geolocation.create({ query? })` returns `{ resolve, defineCountry, getCountry, listCountries, updateCountry, setGeoBlock, blockedRoster }`. `resolve({ cf_country, cf_region?, accept_language?, timezone?, customer_supplied_country? })` returns `{ country, region?, currency, locale, timezone?, geo_blocked, allowed_payment_kinds, allowed_shipping_kinds }`. Pure validators (ISO 3166-1 alpha-2, ISO 4217, BCP-47, IANA timezone shape). RFC 7231 `Accept-Language` q-sorted parser picks the highest-q tag whose primary subtag matches the row default's primary subtag. Customer-supplied country wins only when a row exists; stale values fall through to `cf_country`. Migration `0092_geolocation.sql`. · *`stockTransfers` primitive — audited multi-step stock movement between locations* — `bShop.stockTransfers.create({ query?, inventoryLocations })` returns `{ openTransfer, markShipped, markInTransit, markReceived, reconcile, markException, getTransfer, listOpen, transfersForLocation, discrepanciesFor }`. Six-state FSM: `open → shipped → in_transit → received → reconciled` (happy) or `→ exception` (terminal sad). Composes `inventoryLocations.adjustStock` as the sole owner of `inventory_stock` writes — origin debited at open, destination credited at reconcile (only by the received qty, so short-ships preserve the audit trail of loss). Discrepancy flag computed at reconcile (`quantity_shipped - quantity_received`), surfaces both short-ships (positive) and over-counts (negative). Three tables: `stock_transfers` + `stock_transfer_lines` + append-only `stock_transfer_events` audit log. Migration `0089_stock_transfers.sql`. · *`smsDispatcher` primitive — outbound SMS with opt-in / opt-out / STOP-keyword auto-handling* — `bShop.smsDispatcher.create({ query? })` returns `{ registerProvider, providerForRegion, recordOptIn, recordOptOut, isOptedIn, handleInboundKeyword, enqueue, markDelivered, markFailed, dispatchTick, messagesForCustomer, messageByProviderRef, hashPhone }`. Phone hashed via `namespaceHash('sms-phone', E.164)`. STOP-keyword auto opt-out: any incoming `STOP` / `UNSUBSCRIBE` / `CANCEL` / `END` / `QUIT` / `STOPALL` registers opt-out + sends a single confirmation. Transient-failure retry on `1m / 5m / 30m / 2h / 12h` back-off; terminal after budget. Non-https endpoints refused. Body refusal for emoji-only / control bytes / zero-width / bidi-override. Migration `0088_sms_dispatcher.sql`. · *`emailCampaigns` primitive — operator-scheduled bulk email campaigns* — `bShop.emailCampaigns.create({ query, mailingAudiences, email, emailSuppressions? })` returns `{ defineCampaign, scheduleCampaign, pauseCampaign, resumeCampaign, cancelCampaign, sendNow, dispatchTick, recordEvent, getCampaign, listCampaigns, metricsForCampaign }`. FSM: draft → scheduled → sending → sent, with paused / cancelled off-ramps. Dispatcher drains the audience in pages via `audiences.resolve`, stamps a `delivered` event per successful `mailer.send`, then transitions sending → sent with `recipients_resolved_count` + `sent_count` + `sent_at` populated. Per-recipient send failures are drop-silent so one bad ESP refusal can't stall the campaign — ESP webhook backfill via `recordEvent` records `bounced` / `unsubscribed`. Migration `0087_email_campaigns.sql`. · *`storefrontDashboards` primitive — operator dashboard composition* — `bShop.storefrontDashboards.create({ query?, salesReports?, analytics?, cartAbandonment?, inventoryAlerts? })` returns `{ defineDashboard, getDashboard, listDashboards, updateDashboard, archiveDashboard, cloneDashboard, renderDashboard }`. Layout enum: `grid_2col / grid_3col / single_column / freeform`. Widget kinds: `revenue_chart / top_products / top_customers / inventory_low_stock / cart_abandonment / order_status_funnel / recent_orders / aov_trend / refund_rate`. `renderDashboard({ slug, from, to, locale? })` dispatches each widget to its injected data source; missing sources yield `data: null`; locale falls back via BCP-47 right-trim through the bundled en / es / fr catalogue. `cloneDashboard({ source_slug, new_slug, new_title?, new_owner? })` — clone starts un-archived even if source is archived. Migration `0091_storefront_dashboards.sql`. · *`refundPolicy` primitive — operator-managed refund eligibility rules* — `bShop.refundPolicy.create({ query? })` returns `{ definePolicy, evaluate, getPolicy, listPolicies, updatePolicy, archivePolicy, auditEvaluation, listAudit }`. Walks active policies in `(priority DESC, created_at ASC, slug ASC)`; the first in-scope policy whose preconditions all pass governs. Refund-kind math: `full` and `store_credit_only` cap at `order_total_minor`; `partial` floors `total*bps/10000`; `no_refund` returns `eligible:false`. Restocking fee subtracts from the cap, clamped at zero with `restocking_fee_exceeds_refund` reason. Calendar-day window computed against UTC-midnight boundaries. `auditEvaluation` records every decision for compliance export. Migration `0090_refund_policy.sql`. **Fixed:** *`smsDispatcher` — monotonic `created_at` so back-to-back enqueues paginate stably* — Same-millisecond `enqueue` calls shared a `created_at` value, and the cursor-based pagination on `messagesForCustomer` couldn't tell consecutive rows apart. The primitive now stamps `created_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value.
|
|
12
18
|
|
|
13
19
|
- v0.0.61 (2026-05-22) — **Eleven new primitives: customer import, code minter, storefront forms, cart bulk ops, carrier rates, operator audit log, CMS blocks, gift-card ledger, discount analytics, search facets, dunning.** Eleven primitives in one release. `customerImport` bulk-loads customers from CSV / NDJSON with dedupe by email hash + on_conflict mode. `codeMinter` generates single-use discount codes in bulk over a confusion-resistant alphabet with collision retry. `storefrontForms` is the operator-defined contact / lead-capture / wholesale-application form layer with per-session throttle. `cartBulkOps` is B2B-style cart operations (bulk add, replace, price-list apply, reorder, split). `carrierRates` caches shipping rate quotes per (carrier, service, origin, dest, weight) with TTL. `operatorAuditLog` is the cryptographically-chained tamper-evident audit log for operator-side mutations. `cmsBlocks` is the slot-content CMS for headers, hero copy, footer columns, etc. with markdown render + BCP-47 locale fallback. `giftCardLedger` is the append-only credit/debit/expire ledger on top of `giftcards` with denormalized balance-after column for O(1) balance queries. `discountAnalytics` rolls up coupon impressions + redemptions for operator dashboards. `searchFacets` is the operator-registered facet engine (categorical / numeric_range / boolean) for storefront filter sidebars. `dunning` is the payment-recovery workflow for failed subscription charges. **Added:** *`customerImport` primitive — bulk customer loader with email-hash dedup* — `bShop.customerImport.create({ query?, customers })` returns `{ dryRun, importRows, importFromCsv, importFromNdjson, lastReport, cancelInflight }`. Dedup is by the same `namespaceHash('customer-email', canonical)` digest the `customers` primitive computes — two emails differing only in domain casing collide on a single hash (local-part stays RFC-5321-sensitive). on_conflict modes: `update / skip / error`. CSV-injection refusal happens before the run row opens, so a rejected upload leaves zero rows in both `customers` and `customer_imports`. `cancelInflight` flips status → cancelled and the row-processing loop checks the flag between rows; partial writes persist so the operator re-uploads to fill the gap. Migration `0061_customer_imports.sql`. · *`codeMinter` primitive — bulk single-use discount-code generator* — `bShop.codeMinter.create({ query?, coupons })` returns `{ mintBatch, getBatch, listBatches, codesForBatch, voidBatch, exportBatchCsv, DEFAULT_ALPHABET, STATUSES }`. Default alphabet `23456789ABCDEFGHJKLMNPQRSTUVWXYZ` (32 glyphs skipping confusion-resistant 0 / 1 / I / O). Custom alphabets use rejection sampling against `Math.floor(256/len)*len` to keep the draw uniform. Per-code collision retry budget of 16; bust raises `CODE_MINTER_COLLISION_BUDGET_EXHAUSTED`. The `code_batch_members.coupon_code UNIQUE` constraint is the authoritative cross-batch gate. `voidBatch` archives every member's coupon via `coupons.archive` and flips status → voided. `exportBatchCsv` yields header `coupon_code,minted_at\r\n` then one CRLF row per code, paginated 500 at a time, ordered for byte-stable re-exports. Migration `0075_code_batches.sql`. · *`storefrontForms` primitive — operator-defined contact / lead-capture forms* — `bShop.storefrontForms.create({ query?, notifications?, webhooks? })` returns `{ defineForm, getForm, listForms, updateForm, archiveForm, submit, submissionsForForm, throttleCheck, hashSession }`. Field kinds: `text / email / phone / textarea / select / checkbox / number`. submit_to kinds: `email / webhook`. Per-session throttle bucketed on the SHA3-512 namespace hash of the session id; `throttle=0` bypasses. Persist-then-dispatch flow with `dispatched_at` / `dispatch_error` stamped on the row. Email-field validation routes through `b.guardEmail`. Migration `0076_storefront_forms.sql`. · *`cartBulkOps` primitive — B2B-style bulk cart operations* — `bShop.cartBulkOps.create({ query?, cart, catalog, customers?, lineGrouper? })` returns `{ addLines, replaceLines, clearLines, priceListApply, reorder, splitCart, quoteForCart, priceLists }`. Atomicity via pre-flight validation: every SKU resolves + price-snapshots before any write; bad row throws `lines[N].sku — …` with zero side effects. `priceListApply` resolves slug from explicit input OR `price_list_assignments[customer_id]`; refuses currency mismatch / archived list / unknown slug. `reorder` skip categories surface as `{ sku, qty, reason }` so the storefront renders "couldn't add N items" affordances. `splitCart` requires an operator-supplied `lineGrouper(line, splitBy)` (no built-in vendor/category guesswork); source cart marked `abandoned` on split. Sub-module `priceLists` exposes `{ create, setMember, assign, listMembers }`. Migration `0078_price_lists.sql`. · *`carrierRates` primitive — at-checkout shipping rate cache* — `bShop.carrierRates.create({ query?, cursorSecret? })` returns `{ registerCarrier, recordRateQuote, ratesForShipment, cleanupExpiredQuotes, recentQuotes, metricsForCarrier }`. Carrier enum: `ups / fedex / usps / dhl / flat_rate`. `recordRateQuote` dedup-upserts on `UNIQUE(carrier_slug, service_code, origin_postal, dest_postal, weight_grams)`. `ratesForShipment` returns sorted-by-cost rates from the cache + a synthetic flat-rate fallback when no live rates apply; expired rows + paused carriers excluded. `rate_endpoint` gated via `b.safeUrl` (https-only). `metricsForCarrier({ slug, from, to })` aggregates by (service_code, currency). Migration `0080_carrier_rates.sql`. · *`operatorAuditLog` primitive — cryptographically-chained tamper-evident operator audit log* — `bShop.operatorAuditLog.create({ query? })` returns `{ record, listByActor, listByResource, searchAction, chainHead, verifyChain }`. Each row carries `row_hash = SHA3-512(prev_hash || canonical-json(row-without-hashes))` so the chain is replay-verifiable. `verifyChain()` recomputes hashes left-to-right and flags any tampered row. `occurred_at` is clamped to be strictly monotonic against the chain head so same-millisecond appends still chain deterministically. Actor enum: `operator / system / app`. UA-class enum: `browser / mobile_app / api_client / cli / bot / unknown`. Opaque base64url `(occurred_at, id)` keyset cursor for the list surfaces. Migration `0074_operator_audit_log.sql`. · *`cmsBlocks` primitive — slot-content CMS with BCP-47 locale fallback* — `bShop.cmsBlocks.create({ query?, translations? })` returns `{ defineBlock, setLocalized, getRendered, listBlocks, archiveBlock, update, versionsForBlock }`. Two-table layout (`cms_blocks` PK + `cms_block_localizations` FK CASCADE) so version history per (key, locale) is queryable and `defineBlock` is idempotent without disturbing localizations. `getRendered` resolution order: archived → short-circuit empty; otherwise walk BCP-47 fallback chain (right-to-left subtag strip), pick highest-version row whose publish window covers `now`; finally fall back to `default_body`. Markdown render composes `b.template.escapeHtml` + `b.safeUrl.parse` (https-only allowlist plus /-rooted absolute paths). Migration `0079_cms_blocks.sql`. · *`giftCardLedger` primitive — append-only credit/debit/expire ledger with O(1) balance* — `bShop.giftCardLedger.create({ query?, giftcards? })` returns `{ credit, debit, expire, balance, history, transactionsForOrder, bulkBalance, expiringBalance }`. `balance_after_minor` denormalized per row so current balance is O(1) (ORDER BY occurred_at DESC LIMIT 1). Replay-derivability preserved — SUM(credit) - SUM(debit) - SUM(expire) reconstructs balance and is asserted in the test. Per-card `occurred_at` made strictly monotonic via internal `_resolveOccurredAt` (bumps to `prior + 1` on tie or out-of-order operator-supplied timestamp). `expire` caps at current balance (returns `noop: true` when already drained) so scheduled sweeps degrade gracefully against interim debits. `bulkBalance` returns one row per requested id with `balance_minor: 0` for cards with no ledger rows. `expiringBalance` JOINs against `giftcards.expires_at` (canonical expiry source). Migration `0081_gift_card_ledger.sql`. · *`discountAnalytics` primitive — per-coupon + per-tier-set redemption + revenue-impact aggregates* — `bShop.discountAnalytics.create({ query?, coupons?, quantityDiscounts? })` returns `{ recordImpression, recordRedemption, topCoupons, couponPerformance, tierPerformance, revenueImpact, redemptionFunnel }`. Sessions hashed via `namespaceHash('discount-analytics-session', session_id)` at the boundary. Tier-set redemptions tagged with the `coupon_code = 'tier:<tier_set_id>'` convention so the same table backs both surfaces. Process-local monotonic `_nowMs` so back-to-back impressions in the same wall-clock millisecond chain deterministically. Migration `0073_discount_analytics.sql`. · *`searchFacets` primitive — operator-registered facets for storefront filter sidebars* — `bShop.searchFacets.create({ query?, catalog })` returns `{ defineFacet, getFacets, previewQuery, recordFacetUse, listFacets, updateFacet, archiveFacet }`. Kinds: `categorical / numeric_range / boolean`. `getFacets` computes per-facet `{ key, label, kind, options: [{ value, label, count, selected }] }` using the standard "leave focal facet unconstrained" rule so a selected vendor still shows the other vendors' counts. `previewQuery({ filters, sample? })` returns `{ total, sample }` of products matching the candidate filter set. Refuses bad key shape, bad kind, missing/illegal buckets, display_limit on non-categorical, duplicate bucket labels, inverted bucket bounds, key collisions. Session id namespace-hashed before write on `recordFacetUse`. Migration `0082_search_facets.sql`. · *`dunning` primitive — payment recovery workflow for failed subscription charges* — `bShop.dunning.create({ query, subscriptionBilling, email, notifications })` returns `{ defineDunningPolicy, enrollInvoice, tickDunning, statusForInvoice, historyForInvoice, unsetEnrollment, metricsForPolicy }`. Actions: `retry_charge / send_reminder / pause_subscription / cancel_subscription`. Enrollment FSM: `active / recovered / cancelled / abandoned`. `tickDunning({ now })` advances state with composition into email / notifications / billing dependencies — handle methods are surface-optional so a partial wiring still ticks. `metricsForPolicy` aggregates recovery rate + cancellation rate per policy. Migration `0077_dunning.sql`. **Fixed:** *`discountAnalytics` — monotonic `occurred_at` so impressions in the same millisecond sort deterministically* — `Date.now()` on Windows resolves at ~15ms; three impressions inserted in the same tick shared an `occurred_at` and UUID v7's intra-ms randomness scrambled the secondary `id` ordering. The primitive now stamps `occurred_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value.
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.addressValidation
|
|
4
|
+
* @title Address validation + autocomplete proxy cache — stores
|
|
5
|
+
* operator-supplied normalization results so repeat lookups
|
|
6
|
+
* against the same address don't re-pay the carrier API.
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* The primitive does not call USPS / Smarty / Lob / Google / Melissa
|
|
10
|
+
* itself. The operator wires the actual provider call; this
|
|
11
|
+
* primitive is the storage + cache layer. The operator hands the
|
|
12
|
+
* primitive:
|
|
13
|
+
*
|
|
14
|
+
* - `input` — the raw address (or address-suggest prefix) the
|
|
15
|
+
* user typed.
|
|
16
|
+
* - `normalized_address` — what the carrier normalized it to.
|
|
17
|
+
* - `deliverable` / `classification` / `dpv_match` — the
|
|
18
|
+
* carrier's verdict.
|
|
19
|
+
* - `source` — which carrier produced the answer
|
|
20
|
+
* (`usps / smarty / lob / google / melissa / manual`).
|
|
21
|
+
* - `expires_at` — operator-chosen ms-epoch when the cached row
|
|
22
|
+
* goes stale and `lookupCached` should fall through
|
|
23
|
+
* to a fresh provider call.
|
|
24
|
+
*
|
|
25
|
+
* The same raw address must hit the same cache row, even when the
|
|
26
|
+
* operator's call site renders the input object with the keys in a
|
|
27
|
+
* different order or with a slightly different shape. The primitive
|
|
28
|
+
* derives `input_signature` from
|
|
29
|
+
*
|
|
30
|
+
* b.crypto.namespaceHash("address-validation-input",
|
|
31
|
+
* b.safeJson.canonical(input))
|
|
32
|
+
*
|
|
33
|
+
* so map-order / whitespace fluctuation hashes identically. The
|
|
34
|
+
* schema's UNIQUE on `input_signature` makes `recordValidation` an
|
|
35
|
+
* upsert: a second call against the same signature overwrites the
|
|
36
|
+
* prior row (operators tuning expiry mid-flight get deterministic
|
|
37
|
+
* semantics).
|
|
38
|
+
*
|
|
39
|
+
* Surface:
|
|
40
|
+
* recordValidation({ input, normalized_address, deliverable,
|
|
41
|
+
* classification, dpv_match?, source,
|
|
42
|
+
* order_id?, expires_at })
|
|
43
|
+
* lookupCached(input)
|
|
44
|
+
* recordSuggestion({ partial_input, suggestions, expires_at })
|
|
45
|
+
* lookupSuggestions(partial_input)
|
|
46
|
+
* cleanupExpired({ now? })
|
|
47
|
+
* metricsForSource({ slug, from, to })
|
|
48
|
+
* validationsForOrder(order_id)
|
|
49
|
+
*
|
|
50
|
+
* Composes:
|
|
51
|
+
* - `b.crypto.namespaceHash` — SHA3-512 of the canonical input
|
|
52
|
+
* under the address-validation
|
|
53
|
+
* namespace.
|
|
54
|
+
* - `b.safeJson.canonical` — deterministic JSON serialization
|
|
55
|
+
* (keys sorted, no NaN / Infinity).
|
|
56
|
+
* - `b.uuid.v7` — row PKs.
|
|
57
|
+
*
|
|
58
|
+
* Storage:
|
|
59
|
+
* - `address_validations` + `address_suggestions_cache`
|
|
60
|
+
* (migration `0115_address_validation.sql`).
|
|
61
|
+
*
|
|
62
|
+
* @primitive addressValidation
|
|
63
|
+
* @related b.crypto, b.safeJson, b.uuid, shop.addresses, shop.geolocation
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
var bShop;
|
|
67
|
+
function _b() {
|
|
68
|
+
if (!bShop) bShop = require("./index");
|
|
69
|
+
return bShop.framework;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- constants ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
var INPUT_NAMESPACE = "address-validation-input";
|
|
75
|
+
|
|
76
|
+
var SOURCES = Object.freeze([
|
|
77
|
+
"usps", "smarty", "lob", "google", "melissa", "manual",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
var CLASSIFICATIONS = Object.freeze([
|
|
81
|
+
"residential", "commercial", "po_box", "military", "unknown",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
var MAX_NORMALIZED_JSON_LEN = 16 * 1024;
|
|
85
|
+
var MAX_PARTIAL_INPUT_LEN = 512;
|
|
86
|
+
var MAX_SUGGESTIONS_JSON_LEN = 32 * 1024;
|
|
87
|
+
var MAX_DPV_LEN = 16;
|
|
88
|
+
var MAX_ORDER_ID_LEN = 128;
|
|
89
|
+
|
|
90
|
+
// ---- validators ---------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function _input(value, label) {
|
|
93
|
+
label = label || "input";
|
|
94
|
+
if (value == null) {
|
|
95
|
+
throw new TypeError("addressValidation: " + label + " is required");
|
|
96
|
+
}
|
|
97
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
98
|
+
throw new TypeError("addressValidation: " + label + " must be a plain object");
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _msEpoch(n, label) {
|
|
104
|
+
if (typeof n !== "number" || !isFinite(n) || n < 0 || !Number.isInteger(n)) {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
"addressValidation: " + label + " must be a non-negative integer ms-epoch"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return n;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _bool(v, label) {
|
|
113
|
+
if (typeof v !== "boolean") {
|
|
114
|
+
throw new TypeError("addressValidation: " + label + " must be a boolean");
|
|
115
|
+
}
|
|
116
|
+
return v;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _source(s) {
|
|
120
|
+
if (typeof s !== "string" || SOURCES.indexOf(s) === -1) {
|
|
121
|
+
throw new TypeError(
|
|
122
|
+
"addressValidation: source must be one of " + JSON.stringify(SOURCES) +
|
|
123
|
+
", got " + JSON.stringify(s)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return s;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _classification(s) {
|
|
130
|
+
if (typeof s !== "string" || CLASSIFICATIONS.indexOf(s) === -1) {
|
|
131
|
+
throw new TypeError(
|
|
132
|
+
"addressValidation: classification must be one of " +
|
|
133
|
+
JSON.stringify(CLASSIFICATIONS) + ", got " + JSON.stringify(s)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return s;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _dpvMatch(s) {
|
|
140
|
+
if (s == null) return null;
|
|
141
|
+
if (typeof s !== "string" || !s.length) {
|
|
142
|
+
throw new TypeError("addressValidation: dpv_match must be a non-empty string or null");
|
|
143
|
+
}
|
|
144
|
+
if (s.length > MAX_DPV_LEN) {
|
|
145
|
+
throw new TypeError(
|
|
146
|
+
"addressValidation: dpv_match must be <= " + MAX_DPV_LEN + " characters"
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (/[\x00-\x1F\x7F]/.test(s)) {
|
|
150
|
+
throw new TypeError("addressValidation: dpv_match must not contain control bytes");
|
|
151
|
+
}
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _orderId(s) {
|
|
156
|
+
if (s == null) return null;
|
|
157
|
+
if (typeof s !== "string" || !s.length) {
|
|
158
|
+
throw new TypeError("addressValidation: order_id must be a non-empty string or null");
|
|
159
|
+
}
|
|
160
|
+
if (s.length > MAX_ORDER_ID_LEN) {
|
|
161
|
+
throw new TypeError(
|
|
162
|
+
"addressValidation: order_id must be <= " + MAX_ORDER_ID_LEN + " characters"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (/[\x00-\x1F\x7F]/.test(s)) {
|
|
166
|
+
throw new TypeError("addressValidation: order_id must not contain control bytes");
|
|
167
|
+
}
|
|
168
|
+
return s;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _normalizedAddress(v) {
|
|
172
|
+
if (v == null || typeof v !== "object" || Array.isArray(v)) {
|
|
173
|
+
throw new TypeError(
|
|
174
|
+
"addressValidation: normalized_address must be a plain object"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
var json;
|
|
178
|
+
try { json = _b().safeJson.canonical(v); }
|
|
179
|
+
catch (e) {
|
|
180
|
+
throw new TypeError(
|
|
181
|
+
"addressValidation: normalized_address must be JSON-serializable — " + e.message
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (json.length > MAX_NORMALIZED_JSON_LEN) {
|
|
185
|
+
throw new TypeError(
|
|
186
|
+
"addressValidation: normalized_address canonical-JSON must be <= " +
|
|
187
|
+
MAX_NORMALIZED_JSON_LEN + " bytes"
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return json;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _partialInput(s) {
|
|
194
|
+
if (typeof s !== "string" || !s.length) {
|
|
195
|
+
throw new TypeError("addressValidation: partial_input must be a non-empty string");
|
|
196
|
+
}
|
|
197
|
+
if (s.length > MAX_PARTIAL_INPUT_LEN) {
|
|
198
|
+
throw new TypeError(
|
|
199
|
+
"addressValidation: partial_input must be <= " + MAX_PARTIAL_INPUT_LEN + " characters"
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (/[\x00-\x1F\x7F]/.test(s)) {
|
|
203
|
+
throw new TypeError("addressValidation: partial_input must not contain control bytes");
|
|
204
|
+
}
|
|
205
|
+
return s;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _suggestions(v) {
|
|
209
|
+
if (!Array.isArray(v)) {
|
|
210
|
+
throw new TypeError("addressValidation: suggestions must be an array");
|
|
211
|
+
}
|
|
212
|
+
var json;
|
|
213
|
+
try { json = _b().safeJson.canonical(v); }
|
|
214
|
+
catch (e) {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
"addressValidation: suggestions must be JSON-serializable — " + e.message
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (json.length > MAX_SUGGESTIONS_JSON_LEN) {
|
|
220
|
+
throw new TypeError(
|
|
221
|
+
"addressValidation: suggestions canonical-JSON must be <= " +
|
|
222
|
+
MAX_SUGGESTIONS_JSON_LEN + " bytes"
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return json;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _now() { return Date.now(); }
|
|
229
|
+
|
|
230
|
+
// ---- signature helper --------------------------------------------------
|
|
231
|
+
|
|
232
|
+
function _signatureForInput(input) {
|
|
233
|
+
_input(input, "input");
|
|
234
|
+
var canonical;
|
|
235
|
+
try { canonical = _b().safeJson.canonical(input); }
|
|
236
|
+
catch (e) {
|
|
237
|
+
throw new TypeError(
|
|
238
|
+
"addressValidation: input must be JSON-serializable — " + e.message
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return _b().crypto.namespaceHash(INPUT_NAMESPACE, canonical);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- row -> wire conversions -------------------------------------------
|
|
245
|
+
|
|
246
|
+
function _parseNormalized(raw) {
|
|
247
|
+
try { return JSON.parse(raw); }
|
|
248
|
+
catch (_e) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
"addressValidation: normalized_address_json column is malformed JSON — storage corruption"
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _rowToValidation(row) {
|
|
256
|
+
if (!row) return null;
|
|
257
|
+
return {
|
|
258
|
+
id: row.id,
|
|
259
|
+
input_signature: row.input_signature,
|
|
260
|
+
normalized_address: _parseNormalized(row.normalized_address_json),
|
|
261
|
+
deliverable: Number(row.deliverable) === 1,
|
|
262
|
+
classification: row.classification,
|
|
263
|
+
dpv_match: row.dpv_match == null ? null : row.dpv_match,
|
|
264
|
+
source: row.source,
|
|
265
|
+
order_id: row.order_id == null ? null : row.order_id,
|
|
266
|
+
expires_at: Number(row.expires_at),
|
|
267
|
+
occurred_at: Number(row.occurred_at),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function _rowToSuggestion(row) {
|
|
272
|
+
if (!row) return null;
|
|
273
|
+
var parsed;
|
|
274
|
+
try { parsed = JSON.parse(row.suggestions_json); }
|
|
275
|
+
catch (_e) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
"addressValidation: suggestions_json column is malformed JSON — storage corruption"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
id: row.id,
|
|
282
|
+
partial_input: row.partial_input,
|
|
283
|
+
suggestions: parsed,
|
|
284
|
+
expires_at: Number(row.expires_at),
|
|
285
|
+
occurred_at: Number(row.occurred_at),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- factory -----------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
function create(opts) {
|
|
292
|
+
opts = opts || {};
|
|
293
|
+
var query = opts.query;
|
|
294
|
+
if (!query) {
|
|
295
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function _getValidationBySig(sig) {
|
|
299
|
+
var r = await query(
|
|
300
|
+
"SELECT * FROM address_validations WHERE input_signature = ?1",
|
|
301
|
+
[sig],
|
|
302
|
+
);
|
|
303
|
+
return r.rows[0] || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function _getSuggestionByInput(p) {
|
|
307
|
+
var r = await query(
|
|
308
|
+
"SELECT * FROM address_suggestions_cache WHERE partial_input = ?1",
|
|
309
|
+
[p],
|
|
310
|
+
);
|
|
311
|
+
return r.rows[0] || null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
SOURCES: SOURCES,
|
|
316
|
+
CLASSIFICATIONS: CLASSIFICATIONS,
|
|
317
|
+
|
|
318
|
+
// Stamp a normalization result against the input that produced it.
|
|
319
|
+
// The schema's UNIQUE on input_signature makes this an upsert: a
|
|
320
|
+
// second call against the same input overwrites the prior row.
|
|
321
|
+
// Operators tune cache lifetime by raising / lowering `expires_at`
|
|
322
|
+
// on each fresh write — the cache is operator-managed, not
|
|
323
|
+
// primitive-managed.
|
|
324
|
+
recordValidation: async function (input) {
|
|
325
|
+
if (!input || typeof input !== "object") {
|
|
326
|
+
throw new TypeError("addressValidation.recordValidation: input object required");
|
|
327
|
+
}
|
|
328
|
+
var sig = _signatureForInput(input.input);
|
|
329
|
+
var normalizedJson = _normalizedAddress(input.normalized_address);
|
|
330
|
+
var deliverable = _bool(input.deliverable, "deliverable");
|
|
331
|
+
var classification = _classification(input.classification);
|
|
332
|
+
var dpvMatch = _dpvMatch(input.dpv_match == null ? null : input.dpv_match);
|
|
333
|
+
var source = _source(input.source);
|
|
334
|
+
var orderId = _orderId(input.order_id == null ? null : input.order_id);
|
|
335
|
+
var expiresAt = _msEpoch(input.expires_at, "expires_at");
|
|
336
|
+
var occurredAt = input.occurred_at == null ? _now() : _msEpoch(input.occurred_at, "occurred_at");
|
|
337
|
+
|
|
338
|
+
// Upsert. Existing row -> overwrite every operator-supplied
|
|
339
|
+
// column AND refresh `occurred_at` so `metricsForSource` reflects
|
|
340
|
+
// the latest provider call. The id stays stable so any external
|
|
341
|
+
// reference doesn't dangle.
|
|
342
|
+
var existing = await _getValidationBySig(sig);
|
|
343
|
+
if (existing) {
|
|
344
|
+
await query(
|
|
345
|
+
"UPDATE address_validations SET " +
|
|
346
|
+
"normalized_address_json = ?1, deliverable = ?2, classification = ?3, " +
|
|
347
|
+
"dpv_match = ?4, source = ?5, order_id = ?6, expires_at = ?7, occurred_at = ?8 " +
|
|
348
|
+
"WHERE input_signature = ?9",
|
|
349
|
+
[
|
|
350
|
+
normalizedJson, deliverable ? 1 : 0, classification,
|
|
351
|
+
dpvMatch, source, orderId, expiresAt, occurredAt, sig,
|
|
352
|
+
],
|
|
353
|
+
);
|
|
354
|
+
return _rowToValidation(await _getValidationBySig(sig));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
var id = _b().uuid.v7();
|
|
358
|
+
await query(
|
|
359
|
+
"INSERT INTO address_validations " +
|
|
360
|
+
"(id, input_signature, normalized_address_json, deliverable, classification, " +
|
|
361
|
+
" dpv_match, source, order_id, expires_at, occurred_at) " +
|
|
362
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
|
363
|
+
[
|
|
364
|
+
id, sig, normalizedJson, deliverable ? 1 : 0, classification,
|
|
365
|
+
dpvMatch, source, orderId, expiresAt, occurredAt,
|
|
366
|
+
],
|
|
367
|
+
);
|
|
368
|
+
return _rowToValidation(await _getValidationBySig(sig));
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
// Look up a cached validation by the raw input shape. Returns the
|
|
372
|
+
// hydrated row when fresh, `null` when either no row exists OR the
|
|
373
|
+
// row's `expires_at` is at-or-before now (expired rows are surfaced
|
|
374
|
+
// as a cache miss so the caller falls through to a fresh provider
|
|
375
|
+
// call). `cleanupExpired` actually deletes the row; this method
|
|
376
|
+
// never mutates.
|
|
377
|
+
lookupCached: async function (input) {
|
|
378
|
+
var sig = _signatureForInput(input);
|
|
379
|
+
var row = await _getValidationBySig(sig);
|
|
380
|
+
if (!row) return null;
|
|
381
|
+
var hydrated = _rowToValidation(row);
|
|
382
|
+
if (hydrated.expires_at <= _now()) return null;
|
|
383
|
+
return hydrated;
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
// Compute the cache signature for a raw input — exposed so
|
|
387
|
+
// operators can pre-hash and key external storage off the same
|
|
388
|
+
// shape without re-running the canonical-JSON pipeline.
|
|
389
|
+
signatureFor: function (input) {
|
|
390
|
+
return _signatureForInput(input);
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
// Autocomplete cache: a partial-input string -> array of
|
|
394
|
+
// operator-opaque suggestion objects. Same upsert + expiry shape
|
|
395
|
+
// as the validations table.
|
|
396
|
+
recordSuggestion: async function (input) {
|
|
397
|
+
if (!input || typeof input !== "object") {
|
|
398
|
+
throw new TypeError("addressValidation.recordSuggestion: input object required");
|
|
399
|
+
}
|
|
400
|
+
var partial = _partialInput(input.partial_input);
|
|
401
|
+
var suggestionsJson = _suggestions(input.suggestions);
|
|
402
|
+
var expiresAt = _msEpoch(input.expires_at, "expires_at");
|
|
403
|
+
var occurredAt = input.occurred_at == null ? _now() : _msEpoch(input.occurred_at, "occurred_at");
|
|
404
|
+
|
|
405
|
+
var existing = await _getSuggestionByInput(partial);
|
|
406
|
+
if (existing) {
|
|
407
|
+
await query(
|
|
408
|
+
"UPDATE address_suggestions_cache SET " +
|
|
409
|
+
"suggestions_json = ?1, expires_at = ?2, occurred_at = ?3 " +
|
|
410
|
+
"WHERE partial_input = ?4",
|
|
411
|
+
[suggestionsJson, expiresAt, occurredAt, partial],
|
|
412
|
+
);
|
|
413
|
+
return _rowToSuggestion(await _getSuggestionByInput(partial));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
var id = _b().uuid.v7();
|
|
417
|
+
await query(
|
|
418
|
+
"INSERT INTO address_suggestions_cache " +
|
|
419
|
+
"(id, partial_input, suggestions_json, expires_at, occurred_at) " +
|
|
420
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
421
|
+
[id, partial, suggestionsJson, expiresAt, occurredAt],
|
|
422
|
+
);
|
|
423
|
+
return _rowToSuggestion(await _getSuggestionByInput(partial));
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
lookupSuggestions: async function (partialInput) {
|
|
427
|
+
var partial = _partialInput(partialInput);
|
|
428
|
+
var row = await _getSuggestionByInput(partial);
|
|
429
|
+
if (!row) return null;
|
|
430
|
+
var hydrated = _rowToSuggestion(row);
|
|
431
|
+
if (hydrated.expires_at <= _now()) return null;
|
|
432
|
+
return hydrated;
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
// Delete every row in both tables whose expires_at has elapsed.
|
|
436
|
+
// Returns `{ validations, suggestions, now }` so operators can
|
|
437
|
+
// surface a "swept N stale entries" admin metric. Idempotent —
|
|
438
|
+
// a second call immediately after the first returns zero counts.
|
|
439
|
+
cleanupExpired: async function (input) {
|
|
440
|
+
input = input || {};
|
|
441
|
+
var now = input.now == null ? _now() : _msEpoch(input.now, "now");
|
|
442
|
+
var rV = await query(
|
|
443
|
+
"DELETE FROM address_validations WHERE expires_at <= ?1",
|
|
444
|
+
[now],
|
|
445
|
+
);
|
|
446
|
+
var rS = await query(
|
|
447
|
+
"DELETE FROM address_suggestions_cache WHERE expires_at <= ?1",
|
|
448
|
+
[now],
|
|
449
|
+
);
|
|
450
|
+
return {
|
|
451
|
+
validations: rV.rowCount != null ? Number(rV.rowCount) : 0,
|
|
452
|
+
suggestions: rS.rowCount != null ? Number(rS.rowCount) : 0,
|
|
453
|
+
now: now,
|
|
454
|
+
};
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
// Aggregate per-source counts over the [from, to] window keyed
|
|
458
|
+
// on `occurred_at`. `slug` (the operator-facing arg name from the
|
|
459
|
+
// spec) carries the source enum value; the result splits by
|
|
460
|
+
// classification + deliverable so operators can spot a carrier
|
|
461
|
+
// whose classification mix drifts.
|
|
462
|
+
metricsForSource: async function (input) {
|
|
463
|
+
if (!input || typeof input !== "object") {
|
|
464
|
+
throw new TypeError("addressValidation.metricsForSource: input object required");
|
|
465
|
+
}
|
|
466
|
+
var source = _source(input.slug);
|
|
467
|
+
var from = _msEpoch(input.from, "from");
|
|
468
|
+
var to = _msEpoch(input.to, "to");
|
|
469
|
+
if (to < from) {
|
|
470
|
+
throw new TypeError("addressValidation.metricsForSource: to must be >= from");
|
|
471
|
+
}
|
|
472
|
+
var rows = (await query(
|
|
473
|
+
"SELECT classification, deliverable, COUNT(*) AS row_count " +
|
|
474
|
+
"FROM address_validations " +
|
|
475
|
+
"WHERE source = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3 " +
|
|
476
|
+
"GROUP BY classification, deliverable " +
|
|
477
|
+
"ORDER BY classification ASC, deliverable ASC",
|
|
478
|
+
[source, from, to],
|
|
479
|
+
)).rows;
|
|
480
|
+
var out = [];
|
|
481
|
+
var total = 0;
|
|
482
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
483
|
+
var r = rows[i];
|
|
484
|
+
var n = Number(r.row_count);
|
|
485
|
+
total += n;
|
|
486
|
+
out.push({
|
|
487
|
+
source: source,
|
|
488
|
+
classification: r.classification,
|
|
489
|
+
deliverable: Number(r.deliverable) === 1,
|
|
490
|
+
count: n,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return { source: source, from: from, to: to, total: total, buckets: out };
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
// Every validation row tagged with the given order_id, in
|
|
497
|
+
// recorded-newest-first order. Operators present this list as the
|
|
498
|
+
// audit trail for "show me every address normalization we paid
|
|
499
|
+
// for on order X." Rows the operator never stamped with an
|
|
500
|
+
// order_id are silently excluded.
|
|
501
|
+
validationsForOrder: async function (orderId) {
|
|
502
|
+
if (typeof orderId !== "string" || !orderId.length) {
|
|
503
|
+
throw new TypeError("addressValidation.validationsForOrder: order_id must be a non-empty string");
|
|
504
|
+
}
|
|
505
|
+
if (orderId.length > MAX_ORDER_ID_LEN) {
|
|
506
|
+
throw new TypeError(
|
|
507
|
+
"addressValidation.validationsForOrder: order_id must be <= " + MAX_ORDER_ID_LEN + " characters"
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
var rows = (await query(
|
|
511
|
+
"SELECT * FROM address_validations WHERE order_id = ?1 " +
|
|
512
|
+
"ORDER BY occurred_at DESC, id DESC",
|
|
513
|
+
[orderId],
|
|
514
|
+
)).rows;
|
|
515
|
+
var out = [];
|
|
516
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
517
|
+
out.push(_rowToValidation(rows[i]));
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
module.exports = {
|
|
525
|
+
create: create,
|
|
526
|
+
SOURCES: SOURCES,
|
|
527
|
+
CLASSIFICATIONS: CLASSIFICATIONS,
|
|
528
|
+
INPUT_NAMESPACE: INPUT_NAMESPACE,
|
|
529
|
+
};
|