@voyantjs/promotions 0.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +38 -0
  2. package/dist/events.d.ts +38 -0
  3. package/dist/events.d.ts.map +1 -0
  4. package/dist/events.js +25 -0
  5. package/dist/index.d.ts +11 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +16 -0
  8. package/dist/routes-shared.d.ts +14 -0
  9. package/dist/routes-shared.d.ts.map +1 -0
  10. package/dist/routes-shared.js +3 -0
  11. package/dist/routes.d.ts +345 -0
  12. package/dist/routes.d.ts.map +1 -0
  13. package/dist/routes.js +55 -0
  14. package/dist/schema.d.ts +655 -0
  15. package/dist/schema.d.ts.map +1 -0
  16. package/dist/schema.js +126 -0
  17. package/dist/service-booking-confirmed.d.ts +77 -0
  18. package/dist/service-booking-confirmed.d.ts.map +1 -0
  19. package/dist/service-booking-confirmed.js +134 -0
  20. package/dist/service-boundary-scheduler.d.ts +85 -0
  21. package/dist/service-boundary-scheduler.d.ts.map +1 -0
  22. package/dist/service-boundary-scheduler.js +141 -0
  23. package/dist/service-catalog-evaluator.d.ts +22 -0
  24. package/dist/service-catalog-evaluator.d.ts.map +1 -0
  25. package/dist/service-catalog-evaluator.js +33 -0
  26. package/dist/service-catalog-plane-promotions.d.ts +72 -0
  27. package/dist/service-catalog-plane-promotions.d.ts.map +1 -0
  28. package/dist/service-catalog-plane-promotions.js +119 -0
  29. package/dist/service-evaluator.d.ts +111 -0
  30. package/dist/service-evaluator.d.ts.map +1 -0
  31. package/dist/service-evaluator.js +264 -0
  32. package/dist/service-storefront.d.ts +40 -0
  33. package/dist/service-storefront.d.ts.map +1 -0
  34. package/dist/service-storefront.js +146 -0
  35. package/dist/service.d.ts +120 -0
  36. package/dist/service.d.ts.map +1 -0
  37. package/dist/service.js +296 -0
  38. package/dist/validation.d.ts +140 -0
  39. package/dist/validation.d.ts.map +1 -0
  40. package/dist/validation.js +134 -0
  41. package/dist/workflow-bulk-reindex.d.ts +55 -0
  42. package/dist/workflow-bulk-reindex.d.ts.map +1 -0
  43. package/dist/workflow-bulk-reindex.js +58 -0
  44. package/package.json +120 -0
package/dist/schema.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Promotional offers schema — three tables backing the promotions module.
3
+ *
4
+ * Per docs/architecture/promotions-architecture.md §4:
5
+ * - `promotional_offers` (root): the offer header (name, discount type/value,
6
+ * scope JSONB, conditions JSONB, validity window, optional code).
7
+ * - `promotional_offer_products` (link): denormalized materialization of the
8
+ * offer's product set for the product-shaped scopes (`products`,
9
+ * `categories`, `destinations`). Slice-shaped scopes (`global`, `markets`,
10
+ * `audiences`) leave this table empty for that offer.
11
+ * - `promotional_offer_redemptions` (audit): one row per (offer, booking).
12
+ * Aggregated by the redemption recorder when a booking spans multiple
13
+ * line-item snapshots that share an applied offer.
14
+ *
15
+ * Cross-module FK rules: `product_id` and `booking_id` are plain `text`
16
+ * columns with no `.references()` per the cross-module decoupling rule
17
+ * (see docs/architecture/schema-discipline.md). Cross-module integrity is
18
+ * enforced at the service layer.
19
+ */
20
+ import { typeId } from "@voyantjs/db/lib/typeid-column";
21
+ import { sql } from "drizzle-orm";
22
+ import { boolean, index, integer, jsonb, numeric, pgEnum, pgTable, primaryKey, text, timestamp, uniqueIndex, } from "drizzle-orm/pg-core";
23
+ export const promotionalOfferDiscountTypeEnum = pgEnum("promotional_offer_discount_type", [
24
+ "percentage",
25
+ "fixed_amount",
26
+ ]);
27
+ export const promotionalOffers = pgTable("promotional_offers", {
28
+ id: typeId("promotional_offers"),
29
+ name: text("name").notNull(),
30
+ slug: text("slug").notNull(),
31
+ description: text("description"),
32
+ discountType: promotionalOfferDiscountTypeEnum("discount_type").notNull(),
33
+ /** Required when `discountType = 'percentage'`. e.g. 20.00 → 20% off. */
34
+ discountPercent: numeric("discount_percent", { precision: 5, scale: 2 }),
35
+ /** Required when `discountType = 'fixed_amount'`. */
36
+ discountAmountCents: integer("discount_amount_cents"),
37
+ /** Required when `discountType = 'fixed_amount'`. ISO 4217. */
38
+ currency: text("currency"),
39
+ /** Discriminated union — see §3.2. Source of truth for editing. */
40
+ scope: jsonb("scope").$type().notNull(),
41
+ /** Typed JSONB; `{ minPax?: number }` in v1. */
42
+ conditions: jsonb("conditions").$type().notNull().default({}),
43
+ validFrom: timestamp("valid_from", { withTimezone: true }),
44
+ validUntil: timestamp("valid_until", { withTimezone: true }),
45
+ /** NULL = auto-applied. Non-NULL = code-gated; stored lowercase. */
46
+ code: text("code"),
47
+ stackable: boolean("stackable").notNull().default(false),
48
+ active: boolean("active").notNull().default(true),
49
+ metadata: jsonb("metadata"),
50
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
51
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
52
+ }, (table) => [
53
+ // Hot-path range scan for the rule evaluator: "all currently-valid active offers".
54
+ index("idx_promotional_offers_active_validity").on(table.active, table.validFrom, table.validUntil),
55
+ // Slugs unique across active rows; archiving frees up the slug for reuse.
56
+ uniqueIndex("uidx_promotional_offers_slug_active").on(table.slug).where(sql `active = true`),
57
+ // Code uniqueness: case-insensitive (stored lowercase, compared lowercase),
58
+ // active rows only. Archived offers can recycle their code.
59
+ uniqueIndex("uidx_promotional_offers_code_active")
60
+ .on(sql `lower(code)`)
61
+ .where(sql `code is not null and active = true`),
62
+ ]);
63
+ /**
64
+ * Denormalized scope materialization. Populated by the service layer on
65
+ * offer create/update for `scope.kind ∈ {products, categories, destinations}`.
66
+ * The catalog projection joins against this table on the hot path.
67
+ *
68
+ * `product_id` is a plain text column — no Drizzle `.references()` per the
69
+ * cross-module decoupling rule.
70
+ */
71
+ export const promotionalOfferProducts = pgTable("promotional_offer_products", {
72
+ offerId: text("offer_id")
73
+ .notNull()
74
+ .references(() => promotionalOffers.id, { onDelete: "cascade" }),
75
+ productId: text("product_id").notNull(),
76
+ }, (table) => [
77
+ primaryKey({ columns: [table.offerId, table.productId] }),
78
+ index("idx_pop_product").on(table.productId),
79
+ ]);
80
+ /**
81
+ * Per-booking redemption record. ON DELETE RESTRICT on `offer_id` so an
82
+ * offer with redemptions cannot be deleted (operators must archive instead).
83
+ *
84
+ * The `(offer_id, booking_id)` unique constraint enforces "one row per
85
+ * (offer, booking)" — the redemption recorder aggregates `discount_applied_cents`
86
+ * across multiple line-item snapshots that share an offer before inserting,
87
+ * and the recorder upsert (`ON CONFLICT … DO UPDATE`) is idempotent against
88
+ * subscriber retries.
89
+ */
90
+ export const promotionalOfferRedemptions = pgTable("promotional_offer_redemptions", {
91
+ id: typeId("promotional_offer_redemptions"),
92
+ offerId: text("offer_id")
93
+ .notNull()
94
+ .references(() => promotionalOffers.id, { onDelete: "restrict" }),
95
+ bookingId: text("booking_id").notNull(),
96
+ /** The literal code the customer entered (case preserved); NULL for auto-applied. */
97
+ codeUsed: text("code_used"),
98
+ discountAppliedCents: integer("discount_applied_cents").notNull(),
99
+ currency: text("currency").notNull(),
100
+ redeemedAt: timestamp("redeemed_at", { withTimezone: true }).notNull().defaultNow(),
101
+ }, (table) => [
102
+ index("idx_por_offer").on(table.offerId),
103
+ index("idx_por_booking").on(table.bookingId),
104
+ uniqueIndex("uidx_por_offer_booking").on(table.offerId, table.bookingId),
105
+ ]);
106
+ /**
107
+ * Boundary-scheduler watermark — a single row tracking the last_tick the
108
+ * boundary scheduler observed. Per §9.2 of the architecture doc, the
109
+ * scheduler queries offers whose `valid_from` / `valid_until` falls
110
+ * BETWEEN `last_tick` and `now()` to detect lifecycle transitions.
111
+ *
112
+ * Single-row convention: rows are upserted with id = `BOUNDARY_SCHEDULER_STATE_ID`
113
+ * (typeid `pofs_default`). The unique constraint on `singleton_key` enforces
114
+ * "at most one row" defensively even if a future caller forgot the convention.
115
+ */
116
+ export const promotionalOfferSchedulerState = pgTable("promotional_offer_scheduler_state", {
117
+ id: typeId("promotional_offer_scheduler_state"),
118
+ /**
119
+ * Sentinel column for single-row enforcement — always set to
120
+ * `'singleton'`. The unique index on this column means a second
121
+ * insert with a different id would still fail.
122
+ */
123
+ singletonKey: text("singleton_key").notNull().default("singleton"),
124
+ lastTick: timestamp("last_tick", { withTimezone: true }).notNull(),
125
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
126
+ }, (table) => [uniqueIndex("uidx_pofs_singleton").on(table.singletonKey)]);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Booking-confirmed redemption subscriber — records one row per
3
+ * (offer, booking) in `promotional_offer_redemptions` after a booking
4
+ * commits, by reading `pricing_applied_offers` from `catalog_quotes`
5
+ * (joined to the booking via the existing `consumed_booking_id` column).
6
+ *
7
+ * Why a subscriber rather than a `BookEntityDeps` hook: `bookEntity`
8
+ * does sequential writes without an enclosing `db.transaction(...)`,
9
+ * and the owned `quickCreate` path opens its own transaction in
10
+ * `@voyantjs/finance`. There is no single commit transaction to be
11
+ * atomic with — claiming "atomic with commit" would be misleading.
12
+ *
13
+ * Per docs/architecture/promotions-architecture.md §3.6 + §7.3.
14
+ *
15
+ * The recorder reads from `catalog_quotes` (the source of truth, written
16
+ * by `quoteEntity`) NOT from `booking_catalog_snapshot` to avoid an
17
+ * ordering race with the catalog-bridge's `captureSnapshotGraph`
18
+ * subscriber (both fire on the same `booking.confirmed` event).
19
+ *
20
+ * Idempotent on retry: the unique `(offer_id, booking_id)` index on
21
+ * `promotional_offer_redemptions` (per §4.3) lets the upsert refresh the
22
+ * aggregate cleanly even if the subscriber is replayed.
23
+ */
24
+ import type { AnyDrizzleDb } from "@voyantjs/db";
25
+ export interface RecordRedemptionsResult {
26
+ /** Number of distinct quotes scanned for this booking. */
27
+ quotesScanned: number;
28
+ /** Number of distinct offers aggregated across those quotes. */
29
+ offersFound: number;
30
+ /** Number of redemption rows upserted. Equals `offersFound` on success. */
31
+ rowsUpserted: number;
32
+ }
33
+ /**
34
+ * Aggregate `pricing_applied_offers` across every consumed quote for the
35
+ * booking and upsert one redemption row per offer.
36
+ *
37
+ * Aggregation rules (per §3.5):
38
+ * - Multiple snapshots in the same booking sharing the same offer →
39
+ * ONE redemption row with `discount_applied_cents` summed across all
40
+ * occurrences.
41
+ * - `code_used` defaults to the first non-null `appliedCode` seen for
42
+ * that offer (auto-applied + code-gated never share the same
43
+ * offer ID).
44
+ * - `currency` carried from the AppliedOffer row directly.
45
+ */
46
+ export declare function recordPromotionRedemptionsForBooking(db: AnyDrizzleDb, bookingId: string): Promise<RecordRedemptionsResult>;
47
+ /**
48
+ * Wiring guidance for the operator template — the canonical subscriber
49
+ * pattern (mirrors how `catalog-bridge.ts` wires `captureSnapshotGraph`):
50
+ *
51
+ * eventBus.subscribe<BookingConfirmedEvent>("booking.confirmed", async ({ data }) => {
52
+ * await withDbFromEnv(env, async (db) => {
53
+ * try {
54
+ * await recordPromotionRedemptionsForBooking(db, data.bookingId)
55
+ * } catch (err) {
56
+ * // Failing here leaves the booking committed without a
57
+ * // redemption row. Ops can backfill from
58
+ * // `pricing_applied_offers` on the snapshot. Log so the gap
59
+ * // is visible.
60
+ * console.warn("[promotions] redemption subscriber failed", { …err })
61
+ * }
62
+ * })
63
+ * })
64
+ *
65
+ * The factory is intentionally kept in the template (not here) because:
66
+ * - The subscriber needs the template's `withDbFromEnv` from
67
+ * `templates/operator/src/api/lib/db.ts` (the Pool lifecycle helper
68
+ * introduced in #510 / #512). That helper isn't exported from a
69
+ * package.
70
+ * - Keeping the package package-side core (`recordPromotionRedemptionsForBooking`)
71
+ * and the wiring template-side mirrors the existing catalog-bridge
72
+ * subscriber pattern.
73
+ */
74
+ export declare const __test__: {
75
+ recordPromotionRedemptionsForBooking: typeof recordPromotionRedemptionsForBooking;
76
+ };
77
+ //# sourceMappingURL=service-booking-confirmed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-booking-confirmed.d.ts","sourceRoot":"","sources":["../src/service-booking-confirmed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAMhD,MAAM,WAAW,uBAAuB;IACtC,0DAA0D;IAC1D,aAAa,EAAE,MAAM,CAAA;IACrB,gEAAgE;IAChE,WAAW,EAAE,MAAM,CAAA;IACnB,2EAA2E;IAC3E,YAAY,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,oCAAoC,CACxD,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,uBAAuB,CAAC,CA0ElC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,eAAO,MAAM,QAAQ;;CAA2C,CAAA"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Booking-confirmed redemption subscriber — records one row per
3
+ * (offer, booking) in `promotional_offer_redemptions` after a booking
4
+ * commits, by reading `pricing_applied_offers` from `catalog_quotes`
5
+ * (joined to the booking via the existing `consumed_booking_id` column).
6
+ *
7
+ * Why a subscriber rather than a `BookEntityDeps` hook: `bookEntity`
8
+ * does sequential writes without an enclosing `db.transaction(...)`,
9
+ * and the owned `quickCreate` path opens its own transaction in
10
+ * `@voyantjs/finance`. There is no single commit transaction to be
11
+ * atomic with — claiming "atomic with commit" would be misleading.
12
+ *
13
+ * Per docs/architecture/promotions-architecture.md §3.6 + §7.3.
14
+ *
15
+ * The recorder reads from `catalog_quotes` (the source of truth, written
16
+ * by `quoteEntity`) NOT from `booking_catalog_snapshot` to avoid an
17
+ * ordering race with the catalog-bridge's `captureSnapshotGraph`
18
+ * subscriber (both fire on the same `booking.confirmed` event).
19
+ *
20
+ * Idempotent on retry: the unique `(offer_id, booking_id)` index on
21
+ * `promotional_offer_redemptions` (per §4.3) lets the upsert refresh the
22
+ * aggregate cleanly even if the subscriber is replayed.
23
+ */
24
+ import { catalogQuotesTable } from "@voyantjs/catalog/booking-engine";
25
+ import { eq, sql } from "drizzle-orm";
26
+ import { promotionalOfferRedemptions } from "./schema.js";
27
+ /**
28
+ * Aggregate `pricing_applied_offers` across every consumed quote for the
29
+ * booking and upsert one redemption row per offer.
30
+ *
31
+ * Aggregation rules (per §3.5):
32
+ * - Multiple snapshots in the same booking sharing the same offer →
33
+ * ONE redemption row with `discount_applied_cents` summed across all
34
+ * occurrences.
35
+ * - `code_used` defaults to the first non-null `appliedCode` seen for
36
+ * that offer (auto-applied + code-gated never share the same
37
+ * offer ID).
38
+ * - `currency` carried from the AppliedOffer row directly.
39
+ */
40
+ export async function recordPromotionRedemptionsForBooking(db, bookingId) {
41
+ const rows = await db
42
+ .select({ pricing_applied_offers: catalogQuotesTable.pricing_applied_offers })
43
+ .from(catalogQuotesTable)
44
+ .where(eq(catalogQuotesTable.consumed_booking_id, bookingId));
45
+ if (rows.length === 0) {
46
+ return { quotesScanned: 0, offersFound: 0, rowsUpserted: 0 };
47
+ }
48
+ // Aggregate per offerId across all quotes.
49
+ const aggregated = new Map();
50
+ for (const row of rows) {
51
+ const offers = row.pricing_applied_offers ?? [];
52
+ for (const offer of offers) {
53
+ const existing = aggregated.get(offer.offerId);
54
+ if (existing) {
55
+ existing.discountAppliedCents += offer.discountAppliedCents;
56
+ // First non-null wins — the code-gated offer (if any) is
57
+ // typically a single occurrence.
58
+ if (existing.codeUsed == null && offer.appliedCode != null) {
59
+ existing.codeUsed = offer.appliedCode;
60
+ }
61
+ }
62
+ else {
63
+ aggregated.set(offer.offerId, {
64
+ discountAppliedCents: offer.discountAppliedCents,
65
+ currency: offer.currency,
66
+ codeUsed: offer.appliedCode,
67
+ });
68
+ }
69
+ }
70
+ }
71
+ if (aggregated.size === 0) {
72
+ return { quotesScanned: rows.length, offersFound: 0, rowsUpserted: 0 };
73
+ }
74
+ const insertValues = Array.from(aggregated.entries()).map(([offerId, summary]) => ({
75
+ offerId,
76
+ bookingId,
77
+ codeUsed: summary.codeUsed,
78
+ discountAppliedCents: summary.discountAppliedCents,
79
+ currency: summary.currency,
80
+ }));
81
+ // ON CONFLICT DO UPDATE so subscriber retries refresh the aggregate
82
+ // cleanly — important because the event bus may replay this event.
83
+ // Cast: AnyDrizzleDb's union doesn't unify .insert().onConflictDoUpdate()
84
+ // across drivers at the type level (same workaround as the boundary
85
+ // scheduler).
86
+ await db
87
+ .insert(promotionalOfferRedemptions)
88
+ .values(insertValues)
89
+ .onConflictDoUpdate({
90
+ target: [promotionalOfferRedemptions.offerId, promotionalOfferRedemptions.bookingId],
91
+ // EXCLUDED refers to the would-be-inserted row — we want the freshly-
92
+ // computed aggregate (from `insertValues`) to overwrite any stale prior
93
+ // row, not a no-op self-assignment. Without `excluded.*` here, a partial
94
+ // earlier write would never get corrected on retry / replay despite
95
+ // this code path claiming idempotent refresh semantics.
96
+ set: {
97
+ discountAppliedCents: sql `excluded.discount_applied_cents`,
98
+ codeUsed: sql `excluded.code_used`,
99
+ },
100
+ });
101
+ return {
102
+ quotesScanned: rows.length,
103
+ offersFound: aggregated.size,
104
+ rowsUpserted: aggregated.size,
105
+ };
106
+ }
107
+ /**
108
+ * Wiring guidance for the operator template — the canonical subscriber
109
+ * pattern (mirrors how `catalog-bridge.ts` wires `captureSnapshotGraph`):
110
+ *
111
+ * eventBus.subscribe<BookingConfirmedEvent>("booking.confirmed", async ({ data }) => {
112
+ * await withDbFromEnv(env, async (db) => {
113
+ * try {
114
+ * await recordPromotionRedemptionsForBooking(db, data.bookingId)
115
+ * } catch (err) {
116
+ * // Failing here leaves the booking committed without a
117
+ * // redemption row. Ops can backfill from
118
+ * // `pricing_applied_offers` on the snapshot. Log so the gap
119
+ * // is visible.
120
+ * console.warn("[promotions] redemption subscriber failed", { …err })
121
+ * }
122
+ * })
123
+ * })
124
+ *
125
+ * The factory is intentionally kept in the template (not here) because:
126
+ * - The subscriber needs the template's `withDbFromEnv` from
127
+ * `templates/operator/src/api/lib/db.ts` (the Pool lifecycle helper
128
+ * introduced in #510 / #512). That helper isn't exported from a
129
+ * package.
130
+ * - Keeping the package package-side core (`recordPromotionRedemptionsForBooking`)
131
+ * and the wiring template-side mirrors the existing catalog-bridge
132
+ * subscriber pattern.
133
+ */
134
+ export const __test__ = { recordPromotionRedemptionsForBooking };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Promotions boundary scheduler — emits `promotion.changed` events when
3
+ * offers cross their `valid_from` / `valid_until` boundaries since the
4
+ * last tick.
5
+ *
6
+ * The catalog projection is `now()`-dependent (active offers fire at
7
+ * `valid_from`, expire at `valid_until`). Without a scheduled trigger,
8
+ * an indexed product document continues to show an expired discount
9
+ * until something else reindexes it. This scheduler is what guarantees
10
+ * the storefront eventually sees the boundary transition — within the
11
+ * cron interval (5 min by default in the operator template).
12
+ *
13
+ * Per docs/architecture/promotions-architecture.md §9.2.
14
+ *
15
+ * Operator template wires this to a Cloudflare Workers cron in
16
+ * `src/api/promotion-scheduled.ts` + `wrangler.jsonc`.
17
+ *
18
+ * Idempotent on retry: the scheduler's effect is "emit `promotion.changed`
19
+ * events for crossings since `last_tick`". Re-running with the same
20
+ * `last_tick` re-emits the same events; the catalog bridge's reindex
21
+ * subscriber is idempotent (reindexing the same product twice is a
22
+ * no-op modulo the eventual-consistency window).
23
+ */
24
+ import type { EventBus } from "@voyantjs/core";
25
+ import type { AnyDrizzleDb } from "@voyantjs/db";
26
+ import { type PromotionChangedAffected, type PromotionChangedSource } from "./events.js";
27
+ export interface BoundarySchedulerOptions {
28
+ /**
29
+ * How far back to look on the very first tick (when no watermark row
30
+ * exists yet). Defaults to 24h — covers same-day deployments where the
31
+ * scheduler starts after some offers have already crossed their
32
+ * `valid_from`. Pin to a small value if you'd rather only catch
33
+ * forward-going crossings.
34
+ */
35
+ initialLookbackMs?: number;
36
+ /** Override `now()` for testing. */
37
+ now?: () => Date;
38
+ }
39
+ /**
40
+ * One detected boundary crossing. Returned in `result.crossings` so callers
41
+ * without an event bus (e.g., Cloudflare Workers cron handlers, where the
42
+ * in-process bus from the running app isn't reachable) can dispatch the
43
+ * reindex inline.
44
+ */
45
+ export interface BoundaryCrossing {
46
+ offerId: string;
47
+ source: PromotionChangedSource;
48
+ affected: PromotionChangedAffected;
49
+ }
50
+ export interface BoundarySchedulerResult {
51
+ /** Wall clock at the start of this tick — the new watermark. */
52
+ tickedAt: Date;
53
+ /** Watermark used as the lower bound for the crossing scan. */
54
+ lastTick: Date;
55
+ /** Offers crossing `valid_from` in the window — emit with source="updated". */
56
+ validFromCrossings: number;
57
+ /** Offers crossing `valid_until` in the window — emit with source="expired". */
58
+ validUntilCrossings: number;
59
+ /** Total events emitted to the bus when one was provided (else 0). */
60
+ emitted: number;
61
+ /**
62
+ * Every crossing detected this tick. Always populated — independent of
63
+ * whether an event bus was supplied. Lets cron-style callers dispatch
64
+ * the reindex inline when no bus is reachable.
65
+ */
66
+ crossings: BoundaryCrossing[];
67
+ }
68
+ export interface BoundarySchedulerDeps {
69
+ db: AnyDrizzleDb;
70
+ eventBus?: EventBus;
71
+ }
72
+ /**
73
+ * Run a single boundary-scheduler tick. Safe to call repeatedly; the
74
+ * watermark advances monotonically.
75
+ */
76
+ export declare function runPromotionBoundaryScheduler(deps: BoundarySchedulerDeps, options?: BoundarySchedulerOptions): Promise<BoundarySchedulerResult>;
77
+ declare function readWatermark(db: AnyDrizzleDb, tickedAt: Date, initialLookbackMs: number): Promise<Date>;
78
+ declare function writeWatermark(db: AnyDrizzleDb, tickedAt: Date): Promise<void>;
79
+ export declare const __test__: {
80
+ SINGLETON_KEY: "singleton";
81
+ readWatermark: typeof readWatermark;
82
+ writeWatermark: typeof writeWatermark;
83
+ };
84
+ export {};
85
+ //# sourceMappingURL=service-boundary-scheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-boundary-scheduler.d.ts","sourceRoot":"","sources":["../src/service-boundary-scheduler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAC9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAIhD,OAAO,EAEL,KAAK,wBAAwB,EAE7B,KAAK,sBAAsB,EAC5B,MAAM,aAAa,CAAA;AAYpB,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,oCAAoC;IACpC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,sBAAsB,CAAA;IAC9B,QAAQ,EAAE,wBAAwB,CAAA;CACnC;AAED,MAAM,WAAW,uBAAuB;IACtC,gEAAgE;IAChE,QAAQ,EAAE,IAAI,CAAA;IACd,+DAA+D;IAC/D,QAAQ,EAAE,IAAI,CAAA;IACd,+EAA+E;IAC/E,kBAAkB,EAAE,MAAM,CAAA;IAC1B,gFAAgF;IAChF,mBAAmB,EAAE,MAAM,CAAA;IAC3B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,SAAS,EAAE,gBAAgB,EAAE,CAAA;CAC9B;AAED,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,YAAY,CAAA;IAChB,QAAQ,CAAC,EAAE,QAAQ,CAAA;CACpB;AAED;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,IAAI,EAAE,qBAAqB,EAC3B,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,uBAAuB,CAAC,CAiElC;AA+BD,iBAAe,aAAa,CAC1B,EAAE,EAAE,YAAY,EAChB,QAAQ,EAAE,IAAI,EACd,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,IAAI,CAAC,CASf;AAED,iBAAe,cAAc,CAAC,EAAE,EAAE,YAAY,EAAE,QAAQ,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CA0B7E;AAGD,eAAO,MAAM,QAAQ;;;;CAAmD,CAAA"}
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Promotions boundary scheduler — emits `promotion.changed` events when
3
+ * offers cross their `valid_from` / `valid_until` boundaries since the
4
+ * last tick.
5
+ *
6
+ * The catalog projection is `now()`-dependent (active offers fire at
7
+ * `valid_from`, expire at `valid_until`). Without a scheduled trigger,
8
+ * an indexed product document continues to show an expired discount
9
+ * until something else reindexes it. This scheduler is what guarantees
10
+ * the storefront eventually sees the boundary transition — within the
11
+ * cron interval (5 min by default in the operator template).
12
+ *
13
+ * Per docs/architecture/promotions-architecture.md §9.2.
14
+ *
15
+ * Operator template wires this to a Cloudflare Workers cron in
16
+ * `src/api/promotion-scheduled.ts` + `wrangler.jsonc`.
17
+ *
18
+ * Idempotent on retry: the scheduler's effect is "emit `promotion.changed`
19
+ * events for crossings since `last_tick`". Re-running with the same
20
+ * `last_tick` re-emits the same events; the catalog bridge's reindex
21
+ * subscriber is idempotent (reindexing the same product twice is a
22
+ * no-op modulo the eventual-consistency window).
23
+ */
24
+ import { and, eq, gt, lte } from "drizzle-orm";
25
+ import { PROMOTION_CHANGED_EVENT, } from "./events.js";
26
+ import { promotionalOfferSchedulerState, promotionalOffers, } from "./schema.js";
27
+ import { resolveScopeProductIds } from "./service.js";
28
+ /** Sentinel value for the single-row scheduler-state table's `singleton_key`. */
29
+ const SINGLETON_KEY = "singleton";
30
+ /**
31
+ * Run a single boundary-scheduler tick. Safe to call repeatedly; the
32
+ * watermark advances monotonically.
33
+ */
34
+ export async function runPromotionBoundaryScheduler(deps, options = {}) {
35
+ const initialLookbackMs = options.initialLookbackMs ?? 24 * 60 * 60 * 1000;
36
+ const nowFn = options.now ?? (() => new Date());
37
+ const tickedAt = nowFn();
38
+ const lastTick = await readWatermark(deps.db, tickedAt, initialLookbackMs);
39
+ // Fetch every active offer whose `valid_from` OR `valid_until` falls in
40
+ // the (lastTick, tickedAt] window. Two passes (one per boundary) so we
41
+ // can attribute the right `source` per emission.
42
+ const validFromRows = await deps.db
43
+ .select()
44
+ .from(promotionalOffers)
45
+ .where(and(eq(promotionalOffers.active, true), gt(promotionalOffers.validFrom, lastTick), lte(promotionalOffers.validFrom, tickedAt)));
46
+ const validUntilRows = await deps.db
47
+ .select()
48
+ .from(promotionalOffers)
49
+ .where(and(eq(promotionalOffers.active, true), gt(promotionalOffers.validUntil, lastTick), lte(promotionalOffers.validUntil, tickedAt)));
50
+ // Resolve `affected` once per offer (each call hits the link table) so we
51
+ // can both populate the returned `crossings[]` and feed the optional
52
+ // event bus from the same payload.
53
+ const validFromCrossings = await buildCrossings(deps.db, validFromRows, "updated");
54
+ const validUntilCrossings = await buildCrossings(deps.db, validUntilRows, "expired");
55
+ const crossings = [...validFromCrossings, ...validUntilCrossings];
56
+ let emitted = 0;
57
+ if (deps.eventBus) {
58
+ for (const crossing of crossings) {
59
+ const payload = {
60
+ offerId: crossing.offerId,
61
+ source: crossing.source,
62
+ affected: crossing.affected,
63
+ };
64
+ await deps.eventBus.emit(PROMOTION_CHANGED_EVENT, payload, {
65
+ category: "domain",
66
+ source: "service",
67
+ });
68
+ emitted++;
69
+ }
70
+ }
71
+ await writeWatermark(deps.db, tickedAt);
72
+ return {
73
+ tickedAt,
74
+ lastTick,
75
+ validFromCrossings: validFromRows.length,
76
+ validUntilCrossings: validUntilRows.length,
77
+ emitted,
78
+ crossings,
79
+ };
80
+ }
81
+ async function buildCrossings(db, offers, source) {
82
+ const out = [];
83
+ for (const offer of offers) {
84
+ out.push({
85
+ offerId: offer.id,
86
+ source,
87
+ affected: await resolveAffected(db, offer.scope),
88
+ });
89
+ }
90
+ return out;
91
+ }
92
+ async function resolveAffected(db, scope) {
93
+ // `resolveScopeProductIds` is typed against `PostgresJsDatabase` to match
94
+ // the rest of `service.ts`. The structural compatibility across drizzle
95
+ // driver flavors makes the cast safe at runtime — only `.select(...)` is
96
+ // used here.
97
+ const productIds = await resolveScopeProductIds(db, scope);
98
+ if (productIds === null)
99
+ return { kind: "all" };
100
+ return { kind: "products", productIds };
101
+ }
102
+ async function readWatermark(db, tickedAt, initialLookbackMs) {
103
+ const rows = await db
104
+ .select({ lastTick: promotionalOfferSchedulerState.lastTick })
105
+ .from(promotionalOfferSchedulerState)
106
+ .where(eq(promotionalOfferSchedulerState.singletonKey, SINGLETON_KEY))
107
+ .limit(1);
108
+ const existing = rows[0];
109
+ if (existing)
110
+ return existing.lastTick;
111
+ return new Date(tickedAt.getTime() - initialLookbackMs);
112
+ }
113
+ async function writeWatermark(db, tickedAt) {
114
+ // Upsert keyed on the singleton sentinel — first call inserts, every
115
+ // subsequent call advances the existing row's `last_tick`.
116
+ //
117
+ // Cast: `AnyDrizzleDb` is a union of three driver flavors whose
118
+ // `.insert(...).onConflictDoUpdate(...)` return types differ at the
119
+ // type level (NeonHttp returns a different QueryResult shape than
120
+ // postgres-js / NeonWs). Runtime is identical — drizzle's PgDatabase
121
+ // surface is structurally the same across drivers — so the cast is
122
+ // safe. Same pattern as elsewhere in the workspace where service code
123
+ // accepts the union but uses a write that only typechecks against one
124
+ // concrete driver.
125
+ await db
126
+ .insert(promotionalOfferSchedulerState)
127
+ .values({
128
+ singletonKey: SINGLETON_KEY,
129
+ lastTick: tickedAt,
130
+ updatedAt: tickedAt,
131
+ })
132
+ .onConflictDoUpdate({
133
+ target: promotionalOfferSchedulerState.singletonKey,
134
+ set: {
135
+ lastTick: tickedAt,
136
+ updatedAt: tickedAt,
137
+ },
138
+ });
139
+ }
140
+ // Exposed for tests so they can probe the watermark without recreating the SQL.
141
+ export const __test__ = { SINGLETON_KEY, readWatermark, writeWatermark };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Catalog evaluator adapter — bridges `@voyantjs/catalog`'s
3
+ * `PromotionEvaluationInput` / `PromotionEvaluationOutput` contract to
4
+ * this package's internal evaluator.
5
+ *
6
+ * Wire via:
7
+ *
8
+ * const deps: QuoteEntityDeps = {
9
+ * // ...
10
+ * evaluatePromotions: createCatalogPromotionEvaluator(db),
11
+ * }
12
+ *
13
+ * Per docs/architecture/promotions-architecture.md §3.6 + §7.1.
14
+ */
15
+ import type { PromotionEvaluationInput, PromotionEvaluationOutput } from "@voyantjs/catalog/booking-engine";
16
+ import type { AnyDrizzleDb } from "@voyantjs/db";
17
+ /**
18
+ * Build the `evaluatePromotions` hook the catalog booking engine wires
19
+ * onto `QuoteEntityDeps`. Closes over the request-scoped db.
20
+ */
21
+ export declare function createCatalogPromotionEvaluator(db: AnyDrizzleDb): (input: PromotionEvaluationInput) => Promise<PromotionEvaluationOutput>;
22
+ //# sourceMappingURL=service-catalog-evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-catalog-evaluator.d.ts","sourceRoot":"","sources":["../src/service-catalog-evaluator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EACV,wBAAwB,EACxB,yBAAyB,EAC1B,MAAM,kCAAkC,CAAA;AACzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAIhD;;;GAGG;AACH,wBAAgB,+BAA+B,CAC7C,EAAE,EAAE,YAAY,GACf,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO,CAAC,yBAAyB,CAAC,CAazE"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Catalog evaluator adapter — bridges `@voyantjs/catalog`'s
3
+ * `PromotionEvaluationInput` / `PromotionEvaluationOutput` contract to
4
+ * this package's internal evaluator.
5
+ *
6
+ * Wire via:
7
+ *
8
+ * const deps: QuoteEntityDeps = {
9
+ * // ...
10
+ * evaluatePromotions: createCatalogPromotionEvaluator(db),
11
+ * }
12
+ *
13
+ * Per docs/architecture/promotions-architecture.md §3.6 + §7.1.
14
+ */
15
+ import { createDrizzleOfferDataSource, evaluateOffersForProduct } from "./service-evaluator.js";
16
+ /**
17
+ * Build the `evaluatePromotions` hook the catalog booking engine wires
18
+ * onto `QuoteEntityDeps`. Closes over the request-scoped db.
19
+ */
20
+ export function createCatalogPromotionEvaluator(db) {
21
+ const source = createDrizzleOfferDataSource(db);
22
+ return async (input) => {
23
+ const result = await evaluateOffersForProduct(source, input);
24
+ // Shapes are 1:1 between the two `AppliedOffer` / `CodeStatus`
25
+ // declarations — catalog's contract intentionally mirrors the
26
+ // evaluator's so the bridge is just a structural pass-through.
27
+ return {
28
+ applied: result.applied,
29
+ total: result.total,
30
+ codeStatus: result.codeStatus,
31
+ };
32
+ };
33
+ }