@blamejs/blamejs-shop 0.0.60 → 0.0.62
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 +4 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +21 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +951 -0
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
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.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
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- v0.0.60 (2026-05-22) — **Fourteen new primitives: tax rates, storefront pages, webhook subscriptions, tenants, API keys, barcodes, customer portal, subscription billing, translations, coupon stacking, A/B experiments, print receipts, inventory snapshots, product import.** Fourteen primitives land together. `taxRates` is the operator-managed per-jurisdiction rate table with scheduled effective dates. `storefrontPages` is the markdown-driven CMS for legal / about / shipping copy. `webhookSubscriptions` separates subscription management from delivery. `tenants` runs N branded shops from one deployment with per-tenant domain + currency + theme. `apiKeys` issues hashed-at-rest tokens with scope allowlists + per-key rate limits. `barcodes` maps SKUs to UPC-A / EAN-13 / Code-128 / GTIN-14 values with checksum validation + inline-SVG rendering. `customerPortal` mints single-use scoped portal sessions. `subscriptionBilling` is the invoice + payment-attempt + dunning ledger. `translations` is the per-resource multi-locale lookup with locale-fallback chains. `couponStacking` is the rules engine for which discount codes may be combined. `experiments` is the deterministic-assignment A/B test framework with Wilson confidence intervals. `printReceipts` renders ESC/POS thermal + HTML/PDF + plain-text email receipts. `inventorySnapshots` captures point-in-time stock counts with SHA3-512 tamper-evidence + delta reports. `productImport` is the bulk loader for flat-CSV / Shopify-JSON / blamejs-native catalog data. **Added:** *`taxRates` primitive — per-jurisdiction rate table with scheduled effective dates* — `bShop.taxRates.create({ query? })` returns `{ defineRate, rateFor, listForJurisdiction, updateRate, archiveRate, bulkImport, scheduledChanges }`. Jurisdiction shape is `XX` or `XX-YYY` per ISO 3166. `defineRate` refuses overlapping windows within the same (jurisdiction, category). `rateFor({ jurisdiction, category?, on_date })` resolves category-specific first, then NULL-category fallback. `effective_from` inclusive, `effective_until` exclusive. `scheduledChanges({ from, to })` lists rate transitions in the window for the operator scheduler. Migration `0058_tax_rates.sql` (CHECK enum source: manual / vies / state_dept / avalara). · *`storefrontPages` primitive — markdown CMS for About / Shipping / Privacy / Terms* — `bShop.storefrontPages.create({ query? })` returns `{ defineDraft, publish, unpublish, archive, restore, update, get, getPublished, listPublished, listDrafts, listArchived, renderHtml }`. FSM `draft → published → archived` with `restore` lifting archived → draft. Built-in markdown subset: headings, paragraphs, ul/ol, blockquote, hr, inline code, bold, italic, links. Every text run through `b.template.escapeHtml`; every inline link URL through `b.safeUrl.parse` (https-only + /-rooted internal paths). Unsafe URLs drop the `<a>` entirely so anchor text falls back to inert escaped text. Migration `0059_storefront_pages.sql` (layout enum: default / wide / landing / legal). · *`webhookSubscriptions` primitive — subscription management with 24h secret rotation grace* — `bShop.webhookSubscriptions.create({ query?, now? })` returns `{ subscribe, get, listForOwner, subscriptionsForEvent, pauseSubscription, resumeSubscription, update, unsubscribe, rotateSecret, expireRotationGrace }`. Plaintext signing secret returned ONCE on `subscribe` + `rotate`; at rest only the SHA3-512 namespace hash + a previous-hash for a 24h rotation grace window. `subscriptionsForEvent(event_type)` returns active subscriptions matching the event type for delivery fan-out. `endpoint_url` gated via `b.safeUrl` (https-only). Migration `0060_webhook_subscriptions.sql` (owner_type enum: operator / app / customer). · *`tenants` primitive — multi-store deployment with per-tenant domains + currency + theme* — `bShop.tenants.create({ query? })` returns `{ defineTenant, addDomain, removeDomain, setPrimaryDomain, pauseTenant, resumeTenant, archiveTenant, get, getById, resolveByHost, listTenants, update, stats }`. Slug regex `[a-z0-9]([a-z0-9_-]*[a-z0-9])?` ≤ 64. Domain regex `[a-z0-9][a-z0-9.-]*\.[a-z]{2,}` ≤ 255. `resolveByHost(host)` strips port + lowercases + nearest-match against the registered domain set; paused tenants resolve, archived do not. FSM: `active ↔ paused`, both → `archived` (terminal). Migration `0063_tenants.sql` (`tenants` + `tenant_domains` tables with FK CASCADE). · *`apiKeys` primitive — hashed-at-rest tokens with scope + rate limit + 24h rotation grace* — `bShop.apiKeys.create({ query? })` returns `{ issueKey, verifyToken, revoke, rotate, listForOwner, getKey, update, recordUse, usageForKey, cleanupExpired }`. Plaintext is 32 bytes via `b.crypto.generateBytes` rendered as 43-char URL-safe base64; storage holds only the `namespaceHash('api-key-token', plaintext)` digest. `verifyToken` routes hex compare through `b.crypto.timingSafeEqual`. `rotate` slides the old hash into `token_hash_previous` with a 24h grace + issues a new row. Owner type enum: operator / app / affiliate / tenant. Migration `0064_api_keys.sql` (`api_keys` + append-only `api_key_usage` log). · *`barcodes` primitive — SKU → UPC-A / EAN-13 / Code-128 / GTIN-14 with checksum validation + SVG render* — `bShop.barcodes.create({ query?, catalog })` returns `{ assign, lookup, bySkuList, lookupByValue, unassign, assignAuto, defineRange, listRanges, renderSvg, validateValue }`. Checksum gate per kind (GS1 mod-10 reused for UPC-A / EAN-13 / GTIN-14; Code-128 mod-103 at render). `assignAuto` uses CAS on `next_value` to make concurrent mints race-safe (lost race surfaces as `BARCODE_RANGE_RACE` so the caller can retry). `renderSvg` ships its own EAN/UPC L/G/R tables + ITF-14 widths + Code-128 Set B inline — no external barcode library, no `<script>` / `<foreignObject>` in the output. `validateValue` is a pure-function exported on the module so callers can validate without instantiating the factory. Migration `0068_barcodes.sql`. · *`customerPortal` primitive — single-use scoped customer self-serve sessions* — `bShop.customerPortal.create({ query? })` returns `{ createSession, verifyToken, revokeSession, listForCustomer, expireOlderThan }`. Token plaintext returned once on `createSession`; storage holds only `namespaceHash('customer-portal-token', plaintext)`. Scopes: `full / billing_only / address_only / subscriptions_only / order_history_only`. `verifyToken` flips the row to `consumed` (single-use); constant-time hit/miss equalisation via `b.crypto.timingSafeEqual` on the miss path. Single-use enforced by an `UPDATE … WHERE status='issued'` SQL guard so racing verifies don't both succeed. Default TTL 15 min. Migration `0072_customer_portal_sessions.sql`. · *`subscriptionBilling` primitive — invoice + payment-attempt + dunning ledger* — `bShop.subscriptionBilling.create({ query?, subscriptions, payment? })` returns `{ recordInvoice, recordPaymentAttempt, markPaid, markFailed, enterDunning, exitDunning, invoicesForSubscription, failedInvoices, dunningRoster, arpu }`. Webhook-replay idempotency on `processor_invoice_id` + `(invoice_id, attempt_number)`. Invoice FSM: pending → paid / failed / voided. Dunning states: active / dunning / recovered / cancelled / written_off. `arpu({ from, to })` returns average revenue per user bucketed by currency. `invoice_url` gated via `b.safeUrl`. Migration `0066_subscription_billing.sql` (3 tables with FK CASCADE). · *`translations` primitive — per-resource multi-locale lookup with locale-fallback chain* — `bShop.translations.create({ query? })` returns `{ setTranslation, getTranslation, getForResource, removeTranslation, localesForResource, bulkSet, missingTranslations }`. Locale normalised to BCP-47 canonical form; fallback chain walks right-to-left stripping subtags + appends `en` baseline. `bulkSet` validates every row before writing any (pre-flight-then-write for D1 portability). Values pass through `b.template.escapeHtml` at write so HTML metacharacters land inert while operator-intent newlines survive. `missingTranslations({ resource_kind, locale, sample_size })` reports resources lacking translations in that locale's fallback chain. Migration `0070_translations.sql` (UNIQUE(resource_kind, resource_id, locale, field) backing the upsert). · *`couponStacking` primitive — rules engine for which discount codes may be combined* — `bShop.couponStacking.create({ query?, customerSegments? })` returns `{ definePolicy, getPolicy, listPolicies, updatePolicy, archivePolicy, evaluate, activePoliciesForCart }`. Governing policy = highest-priority active policy whose `order_min_minor` + `customer_segment_in` preconditions match the cart. Conservative default when no policy governs: "first code allowed, rest refused with `no_policy_allows_stacking`". `exclusive_codes` short-circuits + additionally refuses the quantity-discount stack regardless of `with_quantity_discounts`. Refusal reasons: `no_policy_allows_stacking` / `other_codes_not_allowed_to_stack` / `max_codes_per_order_exceeded` / `exclusive_code_present` / `quantity_discount_stack_refused`. Migration `0067_coupon_stacking.sql`. · *`experiments` primitive — A/B testing with deterministic assignment + Wilson confidence intervals* — `bShop.experiments.create({ query? })` returns `{ defineExperiment, getVariant, recordConversion, metricsForExperiment, listExperiments, pauseExperiment, resumeExperiment, archiveExperiment, update }`. Variant assignment is deterministic per session: `namespaceHash('experiments-assign', slug + ':' + sessionHash)` → first 64 bits mod cumulative weight (computed in two 32-bit halves to stay inside JS's 53-bit safe-integer territory). Sessions assigned 100x return the same variant 100x. `variants_json` is read-only after `defineExperiment` — changing variants would corrupt prior assignments. `recordConversion` is drop-silent on unknown experiment / variant / archived / bad-shape (hot-path). `metricsForExperiment` returns per-variant counts + Wilson 95% CI when `assigned_sessions` is supplied. Migration `0071_experiments.sql` (FSM: draft / running / paused / archived terminal). · *`printReceipts` primitive — ESC/POS thermal + HTML/PDF + plain-text receipt rendering* — `bShop.printReceipts.create({ query?, order })` returns `{ thermal, htmlPdf, plainText, previewBuffer, recordPrint, printsForOrder }`. `thermal({ paper_width_mm? })` defaults to 80mm cols; 58mm option supported. `htmlPdf` returns an HTML string suitable for headless-Chrome PDF conversion. `plainText` scrubs C0 control bytes + DEL on output. Every operator-input ship-to field passes through `b.template.escapeHtml` for HTML output. Each print writes a `receipt_prints` audit row with byte size + SHA3-512 hash so an operator can prove what came off the printer. `previewBuffer` returns the rendered string WITHOUT writing the audit row (operator preview path). Migration `0062_print_receipts.sql`. · *`inventorySnapshots` primitive — point-in-time stock captures with SHA3-512 tamper-evidence + delta reports* — `bShop.inventorySnapshots.create({ query?, catalog, inventoryLocations? })` returns `{ takeSnapshot, getSnapshot, listSnapshots, deltaBetween, summary, purgeOlderThan }`. Single-bucket capture reads from `inventory` (location_code NULL); multi-location capture reads from `inventory_stock` when `inventoryLocations` is wired. `deltaBetween({ from_snapshot_id, to_snapshot_id, sku? })` returns per-(sku, location) deltas tagged `added / removed / changed / unchanged`. `summary(snapshot_id)` returns counts + total units + flagged stockout outliers + tamper-evidence check (recomputes the SHA3-512 hash + flags mismatch). `purgeOlderThan(days)` walks expired snapshots — FK CASCADE removes the detail rows. Migration `0065_inventory_snapshots.sql`. · *`productImport` primitive — bulk loader for flat-CSV / Shopify-JSON / blamejs-native catalogs* — `bShop.productImport.create({ query?, catalog })` returns `{ dryRun, importRows, importFromCsv, importFromJson, lastReport, cancelInflight, listImports, errorsForImport }`. Formats: `flat_csv / shopify_json / blamejs_native`. on_conflict modes: `update / skip / error`. Within-import SKU dedupe surfaces as `duplicate_sku_in_import` row-error. Shopify JSON shape converts decimal prices to minor units. RFC-4180 CSV with quoted-comma + quote-doubling. Per-row errors persist to `product_import_errors` for operator triage. Migration `0069_product_imports.sql`.
|
|
12
16
|
|
|
13
17
|
- v0.0.59 (2026-05-22) — **Eleven new primitives: collections, customer segments, recently-viewed, stock alerts, shipping + return labels, promo banners, search synonyms, affiliates, mailing audiences, order timeline.** Eleven primitives ship in one release. `collections` registers manual + smart product groupings. `customerSegments` evaluates RFM-style rules over order history. `recentlyViewed` tracks per-customer / per-session product browse history. `stockAlerts` issues back-in-stock subscriptions with confirmation tokens. `shippingLabels` + `returnLabels` are the carrier-agnostic label-management primitives. `promoBanners` is the storefront CMS for placement-targeted marketing. `searchSynonyms` rewrites and corrects customer search queries. `affiliates` runs the partner program with single-use codes + per-affiliate commission ledgers. `mailingAudiences` is the newsletter segmentation primitive. `orderTimeline` aggregates events from every order-touching primitive into a single chronologically-sorted feed with operator-friendly summary memoization. **Added:** *`collections` primitive — manual + smart product collections* — `bShop.collections.create({ query?, catalog, cursorSecret? })` returns `{ defineManual, defineSmart, get, list, update, archive, addProduct, removeProduct, reorderProducts, productsIn, collectionsForProduct, evaluateRules }`. Manual collections store an explicit ordered membership; smart collections evaluate rules against the catalog at read time. Rules support `eq / neq / contains / gt / gte / lt / lte / in / not_in / between` with field-class guards. `collectionsForProduct(product_id)` unions manual membership with active-smart-rule matches. `update` uses `b.safeSql.assertOneOf` against an `ALLOWED_COLUMNS` allowlist. Migration `0043_collections.sql`. · *`customerSegments` primitive — RFM-style segmentation with cached membership* — `bShop.customerSegments.create({ query?, cursorSecret? })` returns `{ defineSegment, evaluate, recompute, segmentsForCustomer, listSegments, update, archive, unarchive, stats }`. Rules ANDed across `recency_days_max/min`, `frequency_orders_min`, `lifetime_orders_min/max`, `monetary_minor_min/max`, `aov_minor_min`, `refund_rate_bps_min/max`, `last_order_status_in`, `country_in`, `currency_in`. Cancelled orders excluded from aggregates. `recompute()` refreshes the membership cache; subsequent `evaluate(slug)` reads off the cache for paginated iteration. Migration `0049_customer_segments.sql`. · *`recentlyViewed` primitive — per-customer / per-session browse history* — `bShop.recentlyViewed.create({ query?, catalog })` returns `{ recordView, forCustomer, forSession, merge, recommend, cleanupOlderThan, purgeCustomer }`. Session ids are namespace-hashed (`recently-viewed-session`) before write — the log never holds a recoverable identifier. Re-viewing a product within 5 minutes updates the existing row's `last_viewed_at` + increments `view_count` rather than writing a new one. `forCustomer({ exclude_purchased: true })` filters out products the customer has already bought. `merge({ session_id, customer_id })` rolls a guest session into the customer record at login. Sliding-window cap default 20. Migration `0050_recently_viewed.sql`. · *`stockAlerts` primitive — back-in-stock notifications with confirmation tokens* — `bShop.stockAlerts.create({ query?, catalog, notifications? })` returns `{ subscribe, unsubscribe, confirm, isSubscribed, listForSku, listForCustomer, scanAndNotify, cleanupExpired, stats, hashEmail }`. `subscribe` mints a 32-char base64url confirmation token (hash stored, plaintext returned once). `scanAndNotify({ now })` walks pending alerts where `stock_on_hand - stock_held > 0` and (when `notifications` is wired) enqueues an in-app message under event-type `stock.back-in-stock`. Email + token always hashed via `namespaceHash` before write. Migration `0048_stock_alerts.sql`. · *`shippingLabels` primitive — carrier-agnostic shipping label management* — `bShop.shippingLabels.create({ query?, cursorSecret? })` returns `{ requestLabel, markPurchased, voidLabel, markUsed, getLabel, labelsForShipment, labelsForOrder, pendingLabels, voidedInWindow, costsByPeriod, customsForLabel }`. `purchased_via` enum: `easypost / shippo / shipstation / stamps_com / manual / custom`. `label_url` gated through `b.safeUrl.parse` (https-only). `voidLabel` refuses past the carrier's 30-day window with a typed error code. `costsByPeriod({ from, to, carrier? })` groups by purchased_via + currency for cost-allocation dashboards. The actual label-generation HTTP call is the operator's worker. Migration `0051_shipping_labels.sql`. · *`returnLabels` primitive — operator-funded return-shipping flow* — `bShop.returnLabels.create({ query?, returns? })` returns `{ issueLabel, markShipped, markInTransit, markDelivered, markException, getLabel, labelForReturn, pendingPickup, inTransit, eventsForLabel }`. FSM: `issued → shipped → in_transit → delivered`, with an `exception` terminal branch. `markInTransit` accepts re-entry so a multi-hop journey produces a row-per-scan timeline. `markDelivered` calls the injected `returns.markReceived` callback so the RMA flips to `received` status automatically. `issueLabel` refuses unless the underlying return is in `approved` status. Migration `0052_return_labels.sql`. · *`promoBanners` primitive — operator-controlled marketing banners across storefront placements* — `bShop.promoBanners.create({ query?, customerSegments? })` returns `{ defineBanner, activeForPlacement, listAll, getBanner, updateBanner, archive, unarchive, renderHtml, impressionCount, clickCount, recordImpression, recordClick }`. Placements: `top_strip / homepage_hero / pdp_side / cart_side / search_empty / footer`. Audiences: `all / logged_in / guest / segment`. Themes: `info / promo / urgency / success`. `cta_url` gated through `b.safeUrl.parse` (https-only + /-rooted internal paths). `renderHtml` escapes every operator-input field via `b.template.escapeHtml` so script / img-onerror payloads render inert. `recordImpression` / `recordClick` drop-silent on bad slug (hot-path). Migration `0053_promo_banners.sql`. · *`searchSynonyms` primitive — query rewriting + typo correction + stopword removal* — `bShop.searchSynonyms.create({ query? })` returns `{ addGroup, addTypo, addStopword, removeStopword, getGroup, listGroups, updateGroup, deleteGroup, listTypos, listStopwords, rewrite, learnFromQueries }`. Group kinds: `bidirectional` (any term matches any other) + `directional` (terms[0..N-1] all map to terms[N], canonical is terms[N]). `rewrite(query, { include_corrections?, max_expansions? })` returns `{ canonical, expansions, corrections }`. Tokenise → strip control bytes (don't refuse — denial-of-search would block legitimate shoppers) → stopword drop → typo correction → conservative English stemmer (`-ies / -ing / -ed / -es / -s`, min length 4). `learnFromQueries({ from, to, min_count })` scans `search_query_log` for term co-occurrences not yet covered by an existing group. Migration `0055_search_synonyms.sql`. · *`affiliates` primitive — partner program with single-use codes + per-affiliate commission ledger* — `bShop.affiliates.create({ query?, cursorSecret? })` returns `{ registerAffiliate, getAffiliate, affiliateByCode, listAffiliates, updateAffiliate, pauseAffiliate, reinstateAffiliate, recordVisit, attributionForSession, recordCommissionEvent, commissionsForAffiliate, markCommissionPaid, markCommissionVoided, payoutsDue, topAffiliates }`. Codes are 8 chars over a confusion-resistant 32-glyph alphabet. Commission kinds: `percent_bps / amount_per_order_minor / amount_per_signup_minor`. `commission_minor` is computed at write time and stored — historical payouts don't shift when the operator later edits the affiliate's rate. `recordCommissionEvent` is idempotent on `(order_id, affiliate_id)`. Email + visitor session id always hashed via `namespaceHash` before write. Migration `0057_affiliates.sql`. · *`mailingAudiences` primitive — newsletter audience segmentation with suppression filtering* — `bShop.mailingAudiences.create({ query?, newsletter?, emailSuppressions?, cursorSecret? })` returns `{ defineAudience, resolve, count, getAudience, listAudiences, update, archive, recompute, auditDelivery, listDeliveries }`. Audience rules over `newsletter_signups`: `subscribed_after/before`, `source_in`, `tag_any/all`, `country_in`, `double_opt_in`, `language_in`, `customer_status_in`. `resolve({ slug, skip_suppressed: true })` filters out suppressions via the injected `emailSuppressions` peer (default true). Defaults: emails return hashed only; plaintext requires `include_plaintext: true` for verifier-role-gated routes. `recompute()` refreshes the per-slug membership cache. `auditDelivery` writes append-only rows for compliance export. Unsubscribed signups never match any rule. Migration `0056_mailing_audiences.sql` (+ additive columns on `newsletter_signups` for `tags_csv` / `country` / `language` / `customer_status` / `double_opt_in_at`). · *`orderTimeline` primitive — unified per-order event feed across every order-touching primitive* — `bShop.orderTimeline.create({ query?, order?, orderTracking?, orderNotes?, returns?, payment?, fraudScreen?, shippingLabels?, notifications? })` returns `{ forOrder, summarize, customerVisibleFor, compareOrders, recentActivity }`. Aggregates events from `order_transitions`, `shipment_events`, `order_notes`, `return_authorizations`, `fraud_screenings`, `shipping_labels`, and `notifications` into a chronologically-sorted feed newest-first. `customerVisibleFor({ order_id, locale })` filters to events the customer should see (en / es / de built-in, unknown locales fall back to English). `summarize(order_id)` returns `{ status, first_event_at, last_event_at, event_count, milestones: { paid_at?, fulfilled_at?, shipped_at?, delivered_at?, refunded_at? }, cache_hit }`. Cache invalidation tracks both `last_event_at` AND a per-source row count so a same-millisecond event still flushes the cache. Every injected peer is optional. Migration `0054_order_timeline_cache.sql`. **Fixed:** *`returnLabels.issueLabel` — accepts an optional `issued_at` epoch-ms for deterministic test ordering* — Synthetic-timestamp tests that mix `markShipped({ shipped_at: ... })` with the issuer's wall-clock `Date.now()` could see the issued event sort AFTER the synthetic ones. `issueLabel` now accepts `issued_at` so the full timeline can be authored from a single clock source. Production callers omit the field; `Date.now()` is the default. · *`orderTimeline.summarize` — cache freshness check covers every source the collector walks* — `_sourceStats` now joins `shipping_labels` (via `shipments.order_id`) and `notifications` (via `payload_json` `LIKE '%"order_id":"<uuid>"%'`) so the per-source row count matches `_collectAll`. Previously, an order with any shipping-label or notification rows had a mismatched count between cache-write and cache-check, and the cache never registered as fresh. The second `summarize(order_id)` call now correctly hits the cache.
|